diff --git a/cypress.config.ts b/cypress.config.ts index 17161e32..85d2e5b3 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,9 +1,15 @@ -import { defineConfig } from "cypress"; +import { defineConfig } from "cypress" +import { configureVisualRegression } from "cypress-visual-regression/dist/plugin" export default defineConfig({ - e2e: { - setupNodeEvents(on, config) { - // implement node event listeners here + e2e: { + setupNodeEvents(on, config) { + // implement node event listeners here + configureVisualRegression(on) + }, + env: { + visualRegressionType: "regression", + }, + screenshotsFolder: "./cypress/snapshots/actual", }, - }, -}); +}) diff --git a/cypress/e2e/cms/pollenCalendar.cy.ts b/cypress/e2e/cms/pollenCalendar.cy.ts new file mode 100644 index 00000000..7a8353b6 --- /dev/null +++ b/cypress/e2e/cms/pollenCalendar.cy.ts @@ -0,0 +1,139 @@ +import { URLS } from "../consts" +import { loginAttempt, mockValidCredentials, userIsLoggedIn } from "../authenticationUtils" + +function loginToEditPollenPage() { + cy.visit(URLS.EDIT_POLLEN_DATA) + mockValidCredentials() + loginAttempt() + userIsLoggedIn() + cy.visit(URLS.EDIT_POLLEN_DATA) // TODO: login should already redirect here (task #70) + cy.url().should("eq", URLS.EDIT_POLLEN_DATA) +} + +function inputDataFile(filepath: string = "cypress/fixtures/Pollen Dummy Data - valid format.xlsx") { + cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data") + .find("input[type='file']") + .selectFile(filepath) +} + +describe("updating pollen calendar", () => { + beforeEach(loginToEditPollenPage) + + it("successfully preview calendar using valid excel spreadsheet", () => { + inputDataFile("cypress/fixtures/Pollen Dummy Data - valid format.xlsx") + + cy.contains("button", "Preview data").click() + cy.contains("Preview generated ✅") + + cy.contains("button", "Update calendar on website").click() + }) + + describe("invalid filetypes", () => { + it("no file", () => { + cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data").find("input[type='file']") + + cy.contains("button", "Preview data").click() + cy.contains("No file uploaded.") + cy.contains("Preview generated ✅").should("not.exist") + }) + + it("invalid file", () => { + inputDataFile("cypress/fixtures/authSuccessResponse.json") + + cy.contains("button", "Preview data").click() + cy.contains("Uploaded file 'authSuccessResponse.json' is not a .xlsx Excel spreadsheet.") + }) + }) + + describe("invalid excel spreadsheet format", () => { + // invalid formatsare formats that ignore an assumptions made by parsing algorithm + + it("has no worksheets with 'raw' in the name", () => { + inputDataFile("cypress/fixtures/Pollen Dummy Data - invalid format - no 'raw' worksheet.xlsx") + + cy.contains("button", "Preview data").click() + cy.contains( + "This spreadsheet has no worksheet with 'raw' in its name. Names of the worksheets in this spreadsheet: 2022_23_percentage, 2023_24, 2023_24_percentage, 2022_23_old" + ) + cy.contains("Preview generated ✅").should("not.exist") + }) + + it("pollen types are not in column A", () => { + inputDataFile("cypress/fixtures/Pollen Dummy Data - invalid format - pollen types in column B.xlsx") + + cy.contains("button", "Preview data").click() + cy.contains( + `Cell A2 doesn't seem to be a pollen name: undefined. Values in Column A from Row 2 onwards are expected to be pollen names, and the value in the last populated Row in Column A should be "Total pollen counted".` + ) + cy.contains("Preview generated ✅").should("exist") // the other worksheet has a valid format though so preview that data + }) + + it.skip("dates are not in row 1", () => { + inputDataFile("cypress/fixtures/Pollen Dummy Data - invalid format - dates in row 2.xlsx") + + cy.contains("button", "Preview data").click() + cy.contains("Cell B1 doesn't seem to be a date: Pollen Data 2025.") + cy.contains("Preview generated ✅").should("exist") // other worksheet has valid format + }) + + it("doesn't have 'Total pollen counted'", () => { + inputDataFile( + "cypress/fixtures/Pollen Dummy Data - invalid format - sheets don't have 'Total pollen counted'.xlsx" + ) + + cy.contains("button", "Preview data").click() + cy.contains("Worksheet '2023_24_raw' couldn't be parsed because this error occurred:") + cy.contains("A cell containing 'Total pollen counted' was not found in Column A.") + cy.contains("Preview generated ✅").should("not.exist") + }) + }) + + describe("showing calendar preview", () => { + beforeEach(() => inputDataFile("cypress/fixtures/Pollen Dummy Data - valid format.xlsx")) + + // both tests won't work as is because canvas is a visual element + // and its children elements are picked up by the browser :`( + it.skip("chart with a labelled x and y axis", () => { + cy.contains("button", "Preview data").click() + + const yAxisLabel = "Pollen grains per cubic metre of air" + const xAxisLabel = "Date" + cy.contains(yAxisLabel).should("have.length", 1) + cy.contains(xAxisLabel).should("have.length", 1) + }) + + it.skip("check tooltip of a pollen data point", () => { + cy.contains("button", "Preview data").click() + + cy.get("#toolTipButton").trigger("mouseover") + + cy.contains("You hovered over the Button").should("be.visible") + }) + + it("pollen calendar looks as it currently does lol", () => { + cy.contains("button", "Preview data").click() + cy.wait(5000) + + // if it looks '50%' or more different to base snapshot then test fails + cy.compareSnapshot("editPollenCalendarPreview", { errorThreshold: 50 }) + cy.get("canvas").compareSnapshot("editPollenCalendarPreview", { errorThreshold: 50 }) + }) + + it("pollen calendar hover on data point should look as it does right now", () => { + cy.contains("button", "Preview data").click() + cy.wait(5000) + + cy.get("canvas").then(($canvas) => { + const canvasWidth = $canvas.width() + const canvasHeight = $canvas.height() + + let buttonX = canvasWidth * 0.53052 + let buttonY = canvasHeight * 0.6427 + + cy.wrap($canvas).scrollIntoView().realTouch({ x: buttonX, y: buttonY }) + cy.wait(5000) + cy.get("canvas").compareSnapshot("pollenCalendarDataPointHover1") + }) + }) + }) +}) diff --git a/cypress/e2e/cms/pollenData.cy.ts b/cypress/e2e/cms/pollenData.cy.ts deleted file mode 100644 index 80944174..00000000 --- a/cypress/e2e/cms/pollenData.cy.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { URLS } from "../consts" -import { loginAttempt, mockValidCredentials, userIsLoggedIn } from "../authenticationUtils" - -describe("pollen data", () => { - describe("filetype of inputted file", () => { - beforeEach(() => { - cy.visit(URLS.EDIT_POLLEN_DATA) - mockValidCredentials() - loginAttempt() - userIsLoggedIn() - cy.visit(URLS.EDIT_POLLEN_DATA) // TODO: login should already redirect here!!!! - }) - - it("valid excel file", () => { - cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data") - .find("input[type='file']") - .selectFile("cypress/fixtures/Pollen Dummy Data - valid format.xlsx") - - cy.contains("button", "Preview data").click() - cy.contains("Preview generated ✅") - }) - - it("no file", () => { - cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data").find("input[type='file']") - - cy.contains("button", "Preview data").click() - cy.contains("No file uploaded.") - cy.contains("Preview generated ✅").should("not.exist") - }) - - it("invalid file", () => { - cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data") - .find("input[type='file']") - .selectFile("cypress/fixtures/authSuccessResponse.json") - - cy.contains("button", "Preview data").click() - - cy.contains("Uploaded file 'authSuccessResponse.json' is not a .xlsx Excel spreadsheet.") - }) - }) - - describe("excel spreadsheet format", () => { - beforeEach(() => { - cy.visit(URLS.EDIT_POLLEN_DATA) - mockValidCredentials() - loginAttempt() - userIsLoggedIn() - cy.visit(URLS.EDIT_POLLEN_DATA) // TODO: login should already redirect here!!!! - }) - // correct format test is already in the 'inputting excel file' suite - // so testing all invalid formats - // which are formats that ignore an assumptions made by parsing algorithm - - it("No worksheets containing 'raw' in name", () => { - cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data") - .find("input[type='file']") - .selectFile("cypress/fixtures/Pollen Dummy Data - invalid format - no 'raw' worksheet.xlsx") - - cy.contains("button", "Preview data").click() - cy.contains("This spreadsheet has no worksheet with 'raw' in its name.") - cy.contains("Preview generated ✅").should("not.exist") - }) - - it("Pollen types not in Column A", () => { - cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data") - .find("input[type='file']") - .selectFile("cypress/fixtures/Pollen Dummy Data - invalid format - pollen types in column B.xlsx") - - cy.contains("button", "Preview data").click() - cy.contains(`Cell A2 doesn't seem to be a pollen name: undefined.`) - cy.contains("Preview generated ✅").should("not.exist") - }) - - it.skip("Dates are not in row 1", () => { - cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data") - .find("input[type='file']") - .selectFile("cypress/fixtures/Pollen Dummy Data - invalid format - dates in row 2.xlsx") - - cy.contains("button", "Preview data").click() - cy.contains(`Cell B1 doesn't seem to be a date: Pollen Data 2025.`) - cy.contains("Preview generated ✅").should("not.exist") - }) - - it("doesn't have 'Total pollen counted'", () => { - cy.contains("label", "Upload .xlsx Excel spreadsheet containing pollen data") - .find("input[type='file']") - .selectFile( - "cypress/fixtures/Pollen Dummy Data - invalid format - sheet doesn't have 'Total pollen counted'.xlsx" - ) - - cy.contains("button", "Preview data").click() - cy.contains("Worksheet '2023_24_raw' couldn't be parsed because this error occurred:") - cy.contains(`A cell containing 'Total pollen counted' was not found in Column A.`) - cy.contains("Preview generated ✅").should("not.exist") - }) - }) -}) diff --git a/cypress/fixtures/Pollen Dummy Data - invalid format - sheet doesn't have 'Total pollen counted'.xlsx b/cypress/fixtures/Pollen Dummy Data - invalid format - sheets don't have 'Total pollen counted'.xlsx similarity index 68% rename from cypress/fixtures/Pollen Dummy Data - invalid format - sheet doesn't have 'Total pollen counted'.xlsx rename to cypress/fixtures/Pollen Dummy Data - invalid format - sheets don't have 'Total pollen counted'.xlsx index 25c44c05..73fc1a55 100644 Binary files a/cypress/fixtures/Pollen Dummy Data - invalid format - sheet doesn't have 'Total pollen counted'.xlsx and b/cypress/fixtures/Pollen Dummy Data - invalid format - sheets don't have 'Total pollen counted'.xlsx differ diff --git a/cypress/snapshots/.gitignore b/cypress/snapshots/.gitignore new file mode 100644 index 00000000..14441502 --- /dev/null +++ b/cypress/snapshots/.gitignore @@ -0,0 +1,2 @@ +actual +diff \ No newline at end of file diff --git a/cypress/snapshots/base/pollenCalendar.cy.ts/editPollenCalendarPreview.png b/cypress/snapshots/base/pollenCalendar.cy.ts/editPollenCalendarPreview.png new file mode 100644 index 00000000..acb4eba4 Binary files /dev/null and b/cypress/snapshots/base/pollenCalendar.cy.ts/editPollenCalendarPreview.png differ diff --git a/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendar.png b/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendar.png new file mode 100644 index 00000000..616a12ff Binary files /dev/null and b/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendar.png differ diff --git a/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendarDataPointHover1.png b/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendarDataPointHover1.png new file mode 100644 index 00000000..8f55da71 Binary files /dev/null and b/cypress/snapshots/base/pollenCalendar.cy.ts/pollenCalendarDataPointHover1.png differ diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 02867c16..ae56c631 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -14,13 +14,17 @@ // *********************************************************** // Import commands.js using ES2015 syntax: -import './commands' +import "./commands" -Cypress.on('uncaught:exception', (err, runnable) => { +import "cypress-real-events/support" +import { addCompareSnapshotCommand } from "cypress-visual-regression/dist/command" +addCompareSnapshotCommand() + +Cypress.on("uncaught:exception", (err, runnable) => { // returning false here prevents Cypress from // failing the test return false - }) +}) // Alternatively you can use CommonJS syntax: -// require('./commands') \ No newline at end of file +// require('./commands') diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 00000000..6c787768 --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "types": ["cypress", "cypress-visual-regression", "cypress-real-events"], + "resolveJsonModule": true + } +} diff --git a/frontend/app/(cms)/pollen/components/DateInput.tsx b/frontend/app/(cms)/pollen/components/DateInput.tsx new file mode 100644 index 00000000..a14264fc --- /dev/null +++ b/frontend/app/(cms)/pollen/components/DateInput.tsx @@ -0,0 +1,34 @@ +import dayjs from "dayjs" + +export default function DateInput({ lowerLimit, upperLimit, setUpperLimit, setLowerLimit }: any) { + const lowerLimitString = dayjs(lowerLimit).format("YYYY-MM-DD") + const upperLimitString = dayjs(upperLimit).format("YYYY-MM-DD") + + function makeTimestampForDateMidday(timestamp: number) { + return dayjs(timestamp).set("hour", 0).set("minute", 0).set("second", 0).set("millisecond", 0).valueOf() + } + + return ( +
+ + + +
+ ) +} diff --git a/frontend/app/(cms)/pollen/components/MultiChart.tsx b/frontend/app/(cms)/pollen/components/MultiChart.tsx new file mode 100644 index 00000000..4b13a9a8 --- /dev/null +++ b/frontend/app/(cms)/pollen/components/MultiChart.tsx @@ -0,0 +1,138 @@ +import dayjs from "dayjs" +import { FormattedPollenData } from "./util/formatData" +import { memo } from "react" + +import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm" +import "chart.js/auto" +import { Chart } from "react-chartjs-2" + +import { ChartOptions } from "chart.js" + +// includes bar chart & line chart on same axis +const MultiChart = memo(function MultiChart({ + dateUpperLimit = dayjs("2024-07-24").valueOf(), + dateLowerLimit = dayjs("2024-08-24").valueOf(), + pollenData, +}: { + dateUpperLimit: number + dateLowerLimit: number + pollenData: FormattedPollenData +}) { + const { pollenTypes, pollenValues, dailyTotals } = pollenData + + const dateFormat = "DD/MM/YY" + const chartData = { + labels: pollenValues[0].map(({ x }) => dayjs(x).valueOf()), + datasets: [ + ...pollenTypes.map((pollenType, index) => { + const numPollenTypes = pollenTypes.length + const towerEffectWeighting = 0.4 + + return { + label: pollenType, + data: pollenValues[index].map(({ x, y }) => ({ x, y: y * -1 })), + categoryPercentage: + 1 - towerEffectWeighting + (towerEffectWeighting * (numPollenTypes - index)) / numPollenTypes, + barPercentage: 0.8, + type: "bar", + stack: "barStack", + backgroundColor: selectColour(index), + borderColor: selectColour(index), + } + }), + + ...pollenTypes.map((pollenType, index) => { + return { + data: pollenValues[index], + label: pollenType, + type: "line", + borderColor: selectColour(index), + backgroundColor: selectColour(index), + } + }), + + { + label: "Total Pollen", + data: dailyTotals, + borderColor: "black", + + borderDash: [5, 5], + type: "line", + }, + ], + } + const chartOptions: ChartOptions = { + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title: function (context) { + return "Date: " + dayjs(context[0].parsed.x).format(dateFormat) + }, + label: function (context) { + let label = context.dataset.label || "" + + if (label) { + label += ": " + } + if (context.parsed.y !== null) { + label += Math.abs(context.parsed.y) + } + return label + }, + }, + }, + }, + + maintainAspectRatio: false, + parsing: false, + + scales: { + x: { + type: "time", + time: { + unit: "day", + }, + + min: dateLowerLimit, + max: dateUpperLimit + 50, //+50ms so right most pollen values don't get cut off from chart + title: { text: "Date", display: true }, + ticks: { + // For a category axis, the val is the index so the lookup via getLabelForValue is needed + callback: function (val: string | number) { + return dayjs(val).format(dateFormat) + }, + }, + }, + + y: { + suggestedMin: -10, + suggestedMax: 10, + title: { + display: true, + text: "Pollen grains per cubic metre of air", + }, + + ticks: { + // For a category axis, the val is the index so the lookup via getLabelForValue is needed + callback: function (val: string | number, index: number) { + return Math.abs(val as number) + }, + }, + }, + }, + } + + function selectColour(number: number) { + const hue = number * 137.508 // use golden angle approximation + return `hsl(${hue},50%,75%)` + } + + return ( +
+ {chartData && } +
+ ) +}) + +export default MultiChart diff --git a/frontend/app/(cms)/pollen/components/PollenCalendar.tsx b/frontend/app/(cms)/pollen/components/PollenCalendar.tsx new file mode 100644 index 00000000..735f3dc6 --- /dev/null +++ b/frontend/app/(cms)/pollen/components/PollenCalendar.tsx @@ -0,0 +1,102 @@ +import { memo, useEffect, useState } from "react" +import dayjs from "dayjs" +import { PollenData } from "@/app/(cms)/pollen/edit/type/PollenDataType" +import { formatPollenData, FormattedPollenData } from "@/app/(cms)/pollen/components/util/formatData" +import PollenTypeInput from "@/app/(cms)/pollen/components/PollenTypeInput" +import DateInput from "@/app/(cms)/pollen/components/DateInput" +import MultiChart from "@/app/(cms)/pollen/components/MultiChart" + +const PollenCalendar = memo(function PollenCalendar({ pollenData }: { pollenData: PollenData[] }) { + const [showsDateFilter, setShowsDateFilter] = useState(false) + const [dateLowerLimit, setDateLowerLimit] = useState(dayjs("2024-11-28").valueOf()) + const [dateUpperLimit, setDateUpperLimit] = useState(dayjs("2024-12-2").valueOf()) + + const [showsPollenTypeFilter, setShowsPollenTypeFilter] = useState(false) + const [allPollenTypes, setAllPollenTypes] = useState([]) + const [displayedPollenTypes, setDisplayedPollenTypes] = useState([]) + + const [formattedPollenData, setFormattedPollenData] = useState(null) + const [filteredPollenData, setFilteredPollenData] = useState(null) + + useEffect(() => { + const formatted = formatPollenData(pollenData) + setFormattedPollenData(formatted) + + const allPollenNames = formatted.pollenTypes.map((pollenType) => pollenType) + setAllPollenTypes(allPollenNames) + setDisplayedPollenTypes(allPollenNames) + }, [pollenData]) + + useEffect(() => { + if (!formattedPollenData || !displayedPollenTypes.length) return setFilteredPollenData(null) + + const filtered: FormattedPollenData = { + dailyTotals: formattedPollenData.dailyTotals, + pollenTypes: [], + pollenValues: [], + } + + formattedPollenData.pollenTypes.map((pollenType, index) => { + if (!displayedPollenTypes.includes(pollenType)) return + + filtered.pollenTypes.push(pollenType) + filtered.pollenValues.push(formattedPollenData.pollenValues[index]) + }) + + setFilteredPollenData(filtered) + }, [formattedPollenData, displayedPollenTypes]) + + return ( + <> +
+
+

Filter by

+
+ +
+ {allPollenTypes && showsPollenTypeFilter && ( + + )} +
+
+ +
+ + {showsDateFilter && ( + + )} +
+
+ + {filteredPollenData && displayedPollenTypes.length ? ( +
+ +
+ ) : ( +

No pollen types selected 🥲

+ )} +
+ + ) +}) + +export default PollenCalendar diff --git a/frontend/app/(cms)/pollen/components/PollenTypeInput.tsx b/frontend/app/(cms)/pollen/components/PollenTypeInput.tsx new file mode 100644 index 00000000..ad812678 --- /dev/null +++ b/frontend/app/(cms)/pollen/components/PollenTypeInput.tsx @@ -0,0 +1,18 @@ +import Select from "react-select" + +export default function PollenTypeInput({ + allPollenTypes, + displayPollenTypes, +}: { + allPollenTypes: string[] + displayPollenTypes: any +}) { + return ( +