From 9826184b1314b58edbeac9235ad0ed83c2097152 Mon Sep 17 00:00:00 2001 From: Shasan634 Date: Tue, 4 Nov 2025 13:15:02 +0000 Subject: [PATCH 01/11] Combine map controls on compare datasets (#1248) * add multi-map mouse position control * combine zoom controls ## Background ## Why did you take this approach? ## Anything in particular that should be highlighted? ## Screenshot(s) ## Checks - [ ] I ran unit tests. - [ ] I've tested the relevant changes from a user POV. - [ ] I've formatted my Python code using `black .`. _Hint_ To run all python unit tests run the following command from the root repository directory: ```sh bash run_python_tests.sh ``` --- .github/workflows/playwright.yml | 27 + .gitignore | 6 + Front-end_tests/Line.spec.js | 57 + Front-end_tests/Total Area Great Lakes 3.csv | 4 + Front-end_tests/Total Area NA 3.csv | 4 + Front-end_tests/Total Area Salish 3.csv | 4 + Front-end_tests/area_plot_test.spec.js | 171 + Front-end_tests/compare_area_test.spec.js | 175 + Front-end_tests/example.spec.js | 78 + Front-end_tests/line_test.spec.js | 179 + Front-end_tests/point_test.spec.js | 87 + Front-end_tests/single_area_test.spec.js | 103 + Front-end_tests/subset_download_test.spec.js | 120 + Front-end_tests/t.ipynb | 198 + Front-end_tests/test_data | 0 Front-end_tests/test_datasets.json | 126 + Ocean-Navigator-Manual | 1 + oceannavigator/frontend/public/index.html | 2 +- .../frontend/public/oceannavigator.js | 9226 ++++++++++++++--- package.json | 61 + playwright.config.js | 81 + 21 files changed, 9313 insertions(+), 1397 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 Front-end_tests/Line.spec.js create mode 100644 Front-end_tests/Total Area Great Lakes 3.csv create mode 100644 Front-end_tests/Total Area NA 3.csv create mode 100644 Front-end_tests/Total Area Salish 3.csv create mode 100644 Front-end_tests/area_plot_test.spec.js create mode 100644 Front-end_tests/compare_area_test.spec.js create mode 100644 Front-end_tests/example.spec.js create mode 100644 Front-end_tests/line_test.spec.js create mode 100644 Front-end_tests/point_test.spec.js create mode 100644 Front-end_tests/single_area_test.spec.js create mode 100644 Front-end_tests/subset_download_test.spec.js create mode 100644 Front-end_tests/t.ipynb create mode 100644 Front-end_tests/test_data create mode 100644 Front-end_tests/test_datasets.json create mode 160000 Ocean-Navigator-Manual create mode 100644 package.json create mode 100644 playwright.config.js diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..3eb13143c --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,27 @@ +name: Playwright Tests +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 96c967f5a..445e476fb 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,9 @@ data/calculated_parser/parsetab.py node_modules/ oceannavigator/frontend/public/ +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/Front-end_tests/Line.spec.js b/Front-end_tests/Line.spec.js new file mode 100644 index 000000000..103c410a6 --- /dev/null +++ b/Front-end_tests/Line.spec.js @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test'; + +test('generates proper csv file for line plots', async ({ page }) => { + // Set test timeout to 3 minutes (plot generation can take time) + test.setTimeout(180000); + + await page.goto('http://0.0.0.0:8443/public/'); + + // selecting Edit Map Features + await page.locator('.MapTools > button:nth-child(2)').click(); + await page.getByRole('button', { name: 'Add New Feature' }).click(); + await page.getByRole('combobox').nth(3).selectOption({ label: "Line" }); + await page.getByRole('dialog').getByRole('button', { name: '+' }).click(); + await page.locator('[id="0"]').first().fill('-60'); + await page.locator('[id="0"]').nth(1).fill('52'); + await page.locator('[id="1"]').first().fill('-58'); + await page.locator('[id="1"]').nth(1).fill('54'); + await page.getByRole('dialog').getByRole('checkbox').check(); + + // Set up listener for the ACTUAL plot generation API + const plotRequestPromise = page.waitForResponse( + response => { + const url = response.url(); + return url.includes('/api/v2.0/plot/transect') && + url.includes('format=json') && + response.request().method() === 'GET'; + }, + { timeout: 120000 } + ); + + // Click to plot + await page.getByRole('button', { name: 'Plot Selected Features' }).click(); + + // Wait for the Line window/dialog to appear first + await page.waitForSelector('text=Line', { timeout: 10000 }); + + // Now wait for the actual plot API response + const plotResponse = await plotRequestPromise; + // Check if the plot generation failed with 500 + + // Assert successful plot generation + expect(plotResponse.status()).toBe(200); + console.log('✓ Plot generated successfully'); + + // Wait a bit for the plot to render in the UI + await page.waitForTimeout(3000); + + // Verify the plot image loaded correctly + const plotImg = page.getByRole('img', { name: 'Plot' }); + await expect(plotImg).toBeVisible(); + +// const src = await plotImg.getAttribute('src'); + + + +// expect(src).toContain('data:image/png;base64,'); +}); \ No newline at end of file diff --git a/Front-end_tests/Total Area Great Lakes 3.csv b/Front-end_tests/Total Area Great Lakes 3.csv new file mode 100644 index 000000000..795398915 --- /dev/null +++ b/Front-end_tests/Total Area Great Lakes 3.csv @@ -0,0 +1,4 @@ +longitude,latitude +-86.703,47.322 +-87.4289,47.8006 +-86.807,48.0384 diff --git a/Front-end_tests/Total Area NA 3.csv b/Front-end_tests/Total Area NA 3.csv new file mode 100644 index 000000000..b24ce5672 --- /dev/null +++ b/Front-end_tests/Total Area NA 3.csv @@ -0,0 +1,4 @@ +longitude,latitude +-59.3802,45.3181 +-56.9418,45.559 +-57.6699,45.9945 diff --git a/Front-end_tests/Total Area Salish 3.csv b/Front-end_tests/Total Area Salish 3.csv new file mode 100644 index 000000000..c8a37a8c8 --- /dev/null +++ b/Front-end_tests/Total Area Salish 3.csv @@ -0,0 +1,4 @@ +longitude,latitude +-123.68,49.3144 +-123.3265,49.2905 +-123.4583,49.0367 diff --git a/Front-end_tests/area_plot_test.spec.js b/Front-end_tests/area_plot_test.spec.js new file mode 100644 index 000000000..ae61fa823 --- /dev/null +++ b/Front-end_tests/area_plot_test.spec.js @@ -0,0 +1,171 @@ +import { test, expect } from "@playwright/test"; +import datasets from './test_datasets.json'; + + +// Helper function that handles the entire plotting and download process +async function runPlotTest(page, dataset) { + console.log(`Running test for dataset: ${dataset.name}`); + + const consoleErrors = []; + const consoleListener = (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + console.error(`Loading Dataset Error: ${msg.text()}`); + } + }; + page.on("console", consoleListener); + + try { + // Go to main page + await page.goto("http://0.0.0.0:8443/public/"); + // Wait for data to load + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + + // Steps to select dataset on dataset selector + for (const step of dataset.steps) { + await page.getByRole("button", { name: step }).click(); + } + + // Wait for data to load + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + //applying dataset to the main map + await page.getByRole("button", { name: "Go" }).click(); + + //waiting for dataset to be loaded fully + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + // check if there is any console errors after loading the dataset + if (consoleErrors.length > 0) { + throw new Error( + `console errors":\n- ${consoleErrors.join("\n- ")}` + ); + } + + // Open Edit Map Features + await page.locator(".MapTools > button:nth-child(2)").click(); + //ensure edit map feature window opens + await expect(page.getByRole("dialog")).toBeVisible(); + + // choosing area as upload type + //label position varies for SalishSeaCAst 3D Currents but same for the rest + if (dataset.name == "SalishSeaCast 3D Currents") { + const combo = page.getByRole("combobox").nth(3); + await combo.selectOption({ label: "Area" }); + //verifying if label change was successful + await expect(combo).toHaveValue("Polygon"); + } else { + const combo = page.getByRole("dialog").getByRole("combobox"); + await combo.selectOption({ label: "Area" }); + //verifying if label change was successful + await expect(combo).toHaveValue("Polygon"); + } + + // Uploading CSV file + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "Upload CSV" }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(dataset.csvPath); + await page.waitForTimeout(2000); + //verify if csv file uploaded correctly + await expect( + page + .locator("div") + .filter({ hasText: /^PointLineArea\+LongitudeLatitude$/ }) + .first() + ).toBeVisible(); + + // Select checkbox + await page.getByRole("dialog").getByRole("checkbox").check(); + + // Listen for plot request + const plotRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/map") && + url.includes("format=json") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + // Plot + await page.getByRole("button", { name: "Plot Selected Features" }).click(); + + // Check API response + //this check confirms if plot is being sent correctly from the back-end + const plotResponse = await plotRequestPromise; + expect(plotResponse.status()).toBe(200); + + //test to see if dataset was correctly changed in dataset_selector + await expect( + page.locator("#left_map").getByRole("button", { name: dataset.name }) + ).toBeVisible(); + + // selecting arrows from area settings column + if (dataset.arrows != "none") { + const arrowsRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/map") && + url.includes("format=json") && + url.includes("quiver")&& + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + await page.getByRole("combobox").nth(dataset.arrow_box_position).selectOption({ label: dataset.arrows }); + + //waiting for plot with arrows to be generated + const arrowsResponse = await arrowsRequestPromise; + + //checking if plot rendered properly with arrows + expect(arrowsResponse.status()).toBe(200); + } + //selecting Addidtional Contours from Area Settings + if (dataset.contour != "none") { + const contourRequestPromise = page.waitForResponse(response => { + const url = response.url(); + return ( + url.includes('/api/v2.0/plot/map') && + url.includes('format=json') && + url.includes('contour') && + response.request().method() === 'GET' + ); + }, { timeout: 120000 }); + + + await page.getByRole("combobox").nth(dataset.contour_box_position).selectOption({ label: dataset.contour }); + const contourResponse = await contourRequestPromise; + + //checking if plot rendered properly with additional_contours + expect(contourResponse.status()).toBe(200); + } + console.log(`✅ Completed dataset: ${dataset.name}`); + + //**********************************************************************************// + } + + finally { + page.off("console", consoleListener); + } +} + +for (const dataset of datasets) { + + test(`dataset: ${dataset.name}`, async ({ page }) => { + // 4 minutes allocated per dataset + test.setTimeout(240000); + try { + await runPlotTest(page, dataset); + } catch (err) { + console.error(`Error in dataset "${dataset.name}": ${err.message}`); + throw err; + } + }); +} \ No newline at end of file diff --git a/Front-end_tests/compare_area_test.spec.js b/Front-end_tests/compare_area_test.spec.js new file mode 100644 index 000000000..ed952c25e --- /dev/null +++ b/Front-end_tests/compare_area_test.spec.js @@ -0,0 +1,175 @@ +import { test, expect } from "@playwright/test"; +import datasets from "./test_datasets.json"; + +//catches first successful response with a status of 200 +//if no successful response within 120 second it will throw an error +async function waitForSuccessfulResponse(page, matchFn, timeout = 120000) { + return await page.waitForResponse( + (response) => { + try { + if (!matchFn(response)) return false; + const status = response.status(); + return status == 200; + } catch (e) { + return false; + } + }, + { timeout } + ); +} +//Handles area plot compare mode +async function runPlotTest(page, dataset) { + console.log(`Running test for dataset: ${dataset.name}`); + const consoleErrors = []; + const consoleListener = (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + console.error(`Loading Dataset Error: ${msg.text()}`); + } + }; + page.on("console", consoleListener); + try { + + // Go to main page + await page.goto("http://0.0.0.0:8443/public/"); + + // Wait for data to load + await page + .locator("img") + .first() + .waitFor({ state: "visible", timeout: 30000 }); + + // Steps to select dataset on dataset selector + for (const step of dataset.steps) { + await page.getByRole("button", { name: step }).click(); + } + + // Wait for data to load + await page + .locator("img") + .first() + .waitFor({ state: "visible", timeout: 30000 }); + + //applying dataset to the main map + await page.getByRole("button", { name: "Go" }).click(); + + //waiting for dataset to be loaded fully + await page + .locator("img") + .first() + .waitFor({ state: "visible", timeout: 30000 }); + + //checking for errors while loading dataset + if (consoleErrors.length > 0) { + throw new Error( + `Captured console errors for dataset "${ + dataset.name + }":\n- ${consoleErrors.join("\n- ")}` + ); + } + + // Open Edit Map Features + await page.locator(".MapTools > button:nth-child(2)").click(); + + // choosing area as upload type + //label position varies for SalishSeaCAst 3D Currents but same for the rest + if (dataset.name == "SalishSeaCast 3D Currents") { + await page.getByRole("combobox").nth(3).selectOption({ label: "Area" }); + } else { + await page + .getByRole("dialog") + .getByRole("combobox") + .selectOption({ label: "Area" }); + } + + // Uploading CSV file + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "Upload CSV" }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(dataset.csvPath); + await page.waitForTimeout(2000); + + // Select checkbox + await page.getByRole("dialog").getByRole("checkbox").check(); + + // Plot + await page.getByRole("button", { name: "Plot Selected Features" }).click(); + + //clciking on compare Dataset + await page.locator("#dataset_compare").check(); + + //By default every dataset is compared with GIOPS 10 Day Daily Mean + //if we want to compare with other dataset + if (dataset.compare_dataset != "GIOPS 10 Day Daily Mean") { + + //changing the dataset on the right side of the map + await page + .locator("#right_map") + .getByRole("button", { name: "GIOPS 10 Day Daily Mean" }) + .click(); + + for (const step of dataset.compare_steps) { + await page.getByRole("button", { name: step }).click(); + } + //wait for the dataset to be loaded properly + const loadingDialog = page + .getByRole("dialog") + .filter({ hasText: "Loading RIOPS Forecast 3D - Polar Stereographic" }); + + await loadingDialog.waitFor({ state: "detached", timeout: 120000 }); + + // catches the successfull response for the new compare dataset + //Note: When we compare with a dataset, the first api response we get gives a status 500 error + //therefore waitForSuccessfulResponse function ensures that we only take the response that have a status 200. + const [comparePlotResponse] = await Promise.all([ + waitForSuccessfulResponse( + page, + (res) => + res.url().includes("/api/v2.0/plot/map") && + res.url().includes("compare_to") && + res.request().method() === "GET", + 120000 + ), + + page.locator("#right_map").getByRole("button", { name: "Go" }).click(), + + ]); + + expect(comparePlotResponse.status()).toBe(200); + + } else { + + // Wait for the successful response + const [datasetCompareResponse] = await Promise.all([ + waitForSuccessfulResponse( + page, + (res) => + res.url().includes("/api/v2.0/plot/map") && + res.url().includes("compare_to") && + res.request().method() === "GET", + 120000 + ), + + page.locator("#dataset_compare").check(), + + ]); + + expect(datasetCompareResponse.status()).toBe(200); + } + + } finally { + page.off("console", consoleListener); + } +} +for (const dataset of datasets) { + test(`dataset: ${dataset.name}`, async ({ page }) => { + // 4 minutes allocated per dataset + test.setTimeout(240000); + try { + await runPlotTest(page, dataset); + } catch (err) { + console.error(`Error in dataset "${dataset.name}": ${err.message}`); + throw err; + } + }); +} diff --git a/Front-end_tests/example.spec.js b/Front-end_tests/example.spec.js new file mode 100644 index 000000000..9a6836e2e --- /dev/null +++ b/Front-end_tests/example.spec.js @@ -0,0 +1,78 @@ +import { test, expect } from '@playwright/test'; + +test('generates proper csv file for line plots', async ({ page }) => { + // Set test timeout to 3 minutes (plot generation can take time) + test.setTimeout(180000); + + await page.goto('https://www.oceannavigator.ca/public/'); + await page.getByRole('button', { name: 'GIOPS 10 Day Daily Mean' }).click(); + await page.getByRole('button', { name: 'SalishSeaCast' }).click(); + await page.getByRole('button', { name: 'SalishSeaCast 3D Currents'}).click(); + // await page.getByRole('button', { name: 'Copernicus Marine Service Global Ocean 1/12 deg Physics Reanalysis (Monthly)' }) + // await page.getByRole('button', { name: 'CIOPS Forecast East 3D -' }).click(); + await page.waitForTimeout(10000); + await page.getByRole('button', { name: 'Go' }).click(); + await page.waitForTimeout(10000); + await page.locator('.MapTools > button:nth-child(2)').click(); + await page.getByRole('button', { name: 'Add New Feature' }).click(); + await page.getByRole('combobox').nth(3).selectOption({label:'Area'}); + await page.getByRole('dialog').getByRole('button', { name: '+' }).click(); + await page.getByRole('dialog').getByRole('button', { name: '+' }).click(); + await page.locator('[id="0"]').first().fill('-123.68'); + await page.locator('[id="1"]').first().fill('-123.3265'); + await page.locator('[id="2"]').first().fill('-123.4583'); + await page.locator('[id="0"]').nth(1).fill('49.3144'); + await page.locator('[id="1"]').nth(1).fill('49.2905'); + await page.locator('[id="2"]').nth(1).fill('49.0367'); + await page.getByRole('dialog').getByRole('checkbox').click(); + await page.getByRole('button', { name: 'Plot Selected Features' }).click(); + await page.waitForTimeout(10000); + await expect( + page.locator('#left_map').getByRole('button', { name: 'SalishSeaCast 3D Currents' }) +).toBeVisible(); + + await page.getByRole('listbox').selectOption({option:'Speed of Current'}); + await page.getByRole('checkbox', { name: 'Compress as *.zip' }).click(); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + const download = await downloadPromise; + + const suggestedFilename = download.suggestedFilename(); + expect(suggestedFilename).toBeTruthy(); + + //testing for subset netcdf4 file download + + //selecting subset variable + // await page.getByRole("listbox").selectOption({ label: dataset.subset_var }); + // //compressing as zip file + // await page.getByRole("checkbox", { name: "Compress as *.zip" }).click(); + // //listening for download event + // const downloadPromise = page.waitForEvent("download"); + // //clicking save file, should start downlaod + // await page.getByRole("button", { name: "Save", exact: true }).click(); + //check if any error ocurred while downloading the file + // if (consoleErrors.length > 0) { + // throw new Error( + // `Captured console errors for dataset "${ + // dataset.name + // }":\n- ${consoleErrors.join("\n- ")}` + // ); + // } + //download object created, if browser begins downloading + // const download = await downloadPromise; + // //giving a name to the file and ensuring download has started + // //Note: file created will not be stored in the system and automatically cleaned up after test finishes + // const suggestedFilename = download.suggestedFilename(); + // expect(suggestedFilename).toBeTruthy(); + + + + + + + + + + + +}) diff --git a/Front-end_tests/line_test.spec.js b/Front-end_tests/line_test.spec.js new file mode 100644 index 000000000..9dc0412d6 --- /dev/null +++ b/Front-end_tests/line_test.spec.js @@ -0,0 +1,179 @@ +import { test, expect } from "@playwright/test"; +import datasets from './test_datasets.json'; + +async function runPlotTest(page, dataset) { + console.log(`Running test for dataset: ${dataset.name}`); + + const consoleErrors = []; + const consoleListener = (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + console.error(`Loading Dataset Error: ${msg.text()}`); + } + }; + page.on("console", consoleListener); + + try { + // Go to main page + await page.goto("http://0.0.0.0:8443/public/"); + // Wait for data to load + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + + // Steps to select dataset on dataset selector + for (const step of dataset.steps) { + await page.getByRole("button", { name: step }).click(); + } + + // Wait for data to load + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + //applying dataset to the main map + await page.getByRole("button", { name: "Go" }).click(); + + //waiting for dataset to be loaded fully + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + // check if there is any console errors after loading the dataset + if (consoleErrors.length > 0) { + throw new Error( + `console errors":\n- ${consoleErrors.join("\n- ")}` + ); + } + + + //For dataset that don't have data for flemish cap line + if (dataset.line_coordinates!="flemish_cap") + { + await page.locator('.MapTools > button:nth-child(2)').click(); + await page.getByRole('button', { name: 'Add New Feature' }).click(); + await page.getByRole('combobox').nth(3).selectOption({label:'Line'}); + await page.getByRole('dialog').getByRole('button', { name: '+' }).click(); + await page.locator('[id="0"]').first().fill(dataset.line_coordinates[0]); + await page.locator('[id="1"]').first().fill(dataset.line_coordinates[2]); + await page.locator('[id="0"]').nth(1).fill(dataset.line_coordinates[1]); + await page.locator('[id="1"]').nth(1).fill(dataset.line_coordinates[3]); + await page.getByRole('dialog').getByRole('checkbox').click(); + //listen for plot request + const plotRequestPromise = page.waitForResponse( + (response)=>{ + const url = response.url(); + return( + url.includes("/api/v2.0/plot/transect")&& + url.includes('format=json')&& + response.request().method() === "GET" + ); + }, + {timeout: 120000} + ); + await page.getByRole('button', { name: 'Plot Selected Features' }).click(); + + const plotResponse= await plotRequestPromise; + expect(plotResponse.status()).toBe(200); + + } + + else{ + //click on preset features + await page.locator('.MapTools > button:nth-child(3)').click(); + //wait for it to be visible + await page.getByRole('button', { name: 'AZMP Transects', exact: true }).waitFor({ state: "visible", timeout: 120000 }); + + // Listen for AZMP request + const azmpRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/kml/line/AZMP") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + //click on AZMP Transects + await page.getByRole('button', { name: 'AZMP Transects', exact: true }).click(); + + const azmpResponse= await azmpRequestPromise; + expect(azmpResponse.status()).toBe(200); + + // Open Edit Map Features + await page.locator(".MapTools > button:nth-child(2)").click(); + + //select flemish cap + await page.getByRole('checkbox').nth(4).check(); + + if (dataset.line_transect=="no") + { + // if dataset doesnt support line transect then hit plot and then go to hovmoller + await page.getByRole("button", { name: "Plot Selected Features" }).click(); + + } + else{ + //listen for plot request + const plotRequestPromise = page.waitForResponse( + (response)=>{ + const url = response.url(); + return( + url.includes("/api/v2.0/plot/transect")&& + url.includes('format=json')&& + response.request().method() === "GET" + ); + }, + {timeout: 120000} + ); + + // Plot + await page.getByRole("button", { name: "Plot Selected Features" }).click(); + + // Check API response + //this check confirms if plot is being sent correctly from the back-end + const plotResponse = await plotRequestPromise; + expect(plotResponse.status()).toBe(200); + + //test to see if dataset was correctly changed in dataset_selector + await expect( + page.locator("#left_map").getByRole("button", { name: dataset.name }) + ).toBeVisible(); +} + } + +//*********************************************************************************************** +// Hovmoller Diagram test +//*********************************************************************************************** + // Listen for plot request + const hovmollerPlotRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/plot/hovmoller") && + url.includes("format=json") && + response.request().method() === "GET" + ); + }, + { timeout: 120000 } + ); + + await page.getByRole('button', { name: 'Hovmöller Diagram' }).click(); + const hovmollerPlotResponse = await hovmollerPlotRequestPromise; + expect(hovmollerPlotResponse.status()).toBe(200) + console.log(`Test passed for: ${dataset.name}`); + + } + finally { + page.off("console", consoleListener); + } +} +for (const dataset of datasets) { + + test(`dataset: ${dataset.name}`, async ({ page }) => { + // 2 minutes allocated per dataset + test.setTimeout(120000); + try { + await runPlotTest(page, dataset); + } catch (err) { + console.error(`Error in dataset "${dataset.name}": ${err.message}`); + throw err; + } + }); +} \ No newline at end of file diff --git a/Front-end_tests/point_test.spec.js b/Front-end_tests/point_test.spec.js new file mode 100644 index 000000000..86e03c7c7 --- /dev/null +++ b/Front-end_tests/point_test.spec.js @@ -0,0 +1,87 @@ +import { test, expect } from "@playwright/test"; +import datasets from './test_datasets.json'; + +async function runPlotTest(page, dataset) { + console.log(`Running test for dataset: ${dataset.name}`); + + const consoleErrors = []; + const consoleListener = (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + console.error(`Loading Dataset Error: ${msg.text()}`); + } + }; + page.on("console", consoleListener); + + try { + // Go to main page + await page.goto("http://0.0.0.0:8443/public/"); + // Wait for data to load + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + + // Steps to select dataset on dataset selector + for (const step of dataset.steps) { + await page.getByRole("button", { name: step }).click(); + } + + // Wait for data to load + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + //applying dataset to the main map + await page.getByRole("button", { name: "Go" }).click(); + + //waiting for dataset to be loaded fully + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + // check if there is any console errors after loading the dataset + if (consoleErrors.length > 0) { + throw new Error( + `console errors":\n- ${consoleErrors.join("\n- ")}` + ); + } + // Open Edit Map Features + await page.locator(".MapTools > button:nth-child(2)").click(); + await page.getByRole('button', { name: 'Add New Feature' }).click(); + await page.locator('[id="0"]').first().fill(dataset.point_coordinates[0]); + await page.locator('[id="0"]').nth(1).fill(dataset.point_coordinates[1]); + await page.getByRole('dialog').getByRole('checkbox').click(); + const plotRequestPromise = page.waitForResponse( + (response)=>{ + const url = response.url(); + return( + url.includes("/api/v2.0/plot/profile")&& + url.includes('format=json')&& + response.request().method() === "GET" + ); + }, + {timeout: 120000} + ); + await page.getByRole('button', { name: 'Plot Selected Features' }).click(); + + const plotResponse= await plotRequestPromise; + expect(plotResponse.status()).toBe(200); + await page.getByRole('button', { name: 'Virtual Mooring' }).click() + + + + + } + finally{ + page.off("console", consoleListener); + } +} + +for (const dataset of datasets) { + + test(`dataset: ${dataset.name}`, async ({ page }) => { + // 4 minutes allocated per dataset + test.setTimeout(240000); + try { + await runPlotTest(page, dataset); + } catch (err) { + console.error(`Error in dataset "${dataset.name}": ${err.message}`); + throw err; + } + }); +} \ No newline at end of file diff --git a/Front-end_tests/single_area_test.spec.js b/Front-end_tests/single_area_test.spec.js new file mode 100644 index 000000000..a1eadabe1 --- /dev/null +++ b/Front-end_tests/single_area_test.spec.js @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test'; + +test('generates proper csv file for area plots', async ({ page }) => { + // Set test timeout to 3 minutes (plot generation can take time) + test.setTimeout(180000); + + await page.goto('http://0.0.0.0:8443/public/'); + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + + await page.getByRole('button', { name: 'GIOPS 10 Day Daily Mean' }).click(); + await page.getByRole('button', { name: 'RIOPS Forecast' }).click(); + await page.getByRole('button', { name: 'CCG RIOPS Forecast Surface -' }).click(); + await page.getByRole('button', { name: 'Go' }).click(); + await page.waitForTimeout(10000); + + // selecting Edit Map Features + await page.locator('.MapTools > button:nth-child(2)').click(); + + // setting upload type to area before uploading csv file + await page.getByRole('dialog').getByRole('combobox').selectOption({ label: 'Area' }); + + // Setting up file chooser listener BEFORE clicking the upload button + const fileChooserPromise = page.waitForEvent('filechooser'); + + // Click on upload CSV + await page.getByRole('button', { name: 'Upload CSV' }).click(); + + // Waiting for the file chooser to appear + const fileChooser = await fileChooserPromise; + + // Uploading the file + await fileChooser.setFiles('/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area NA 3.csv'); + + // Waiting a moment for the file to be processed + await page.waitForTimeout(2000); + + // selecting the uploaded coordinates/feature + await page.getByRole('dialog').getByRole('checkbox').check(); + + //listener for the plot generation API response + const plotRequestPromise = page.waitForResponse( + response => { + const url = response.url(); + return url.includes('/api/v2.0/plot/map') && + url.includes('format=json') && + response.request().method() === 'GET'; + }, + { timeout: 120000 } + ); + + // plotting the selected feature + await page.getByRole('button', { name: 'Plot Selected Features' }).click(); + + + // Wait for the Area window to appear + await page.waitForSelector('text=Area - 4 Vertices', { timeout: 10000 }); + + // Now waiting for the actual plot API response + const plotResponse = await plotRequestPromise; + + // Assert successful plot generation + expect(plotResponse.status()).toBe(200); + + //selecting water variable in the Arrows section + await page.getByRole('combobox').nth(2).selectOption({ label: 'Water Velocity' }) + await page.waitForTimeout(10000); + + const plotImg = page.getByRole('img', { name: 'Plot' }); + const src = await plotImg.getAttribute('src'); + //assertion to check if the src points to sad-computer.png + //A final check to see if plot is being displayed or not + expect(src).toContain('data:image/png;base64,'); + + + //Subset NETCDF test + + //change variable to speed of sound + await page.getByRole('listbox').selectOption({label: 'Potential Temperature'}) + + //Zip file + await page.getByRole('checkbox', { name: 'Compress as *.zip' }).click(); + + //listening for download event + const downloadPromise = page.waitForEvent('download'); + + //clicking save file, should start downlaod + await page.getByRole('button', { name: 'Save', exact: true }).click(); + + //download object created, if browser begins downloading + const download = await downloadPromise; + //giving a name to the file and ensuring download has started + //Note: file created will not be stored in the system and automatically cleaned up after test finishes + const suggestedFilename = download.suggestedFilename(); + expect(suggestedFilename).toBeTruthy(); + + //close the page + await page.getByRole('button', { name: 'Close' }).nth(1).click(); + await page.waitForTimeout(5000); + + + +}); \ No newline at end of file diff --git a/Front-end_tests/subset_download_test.spec.js b/Front-end_tests/subset_download_test.spec.js new file mode 100644 index 000000000..60fd46c7b --- /dev/null +++ b/Front-end_tests/subset_download_test.spec.js @@ -0,0 +1,120 @@ +import { test, expect } from "@playwright/test"; +import datasets from './test_datasets.json'; + +// handles subset download +async function runPlotTest(page, dataset) { + console.log(`Running test for dataset: ${dataset.name}`); + const consoleErrors = []; + const consoleListener = (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + console.error(`Loading Dataset Error: ${msg.text()}`); + } + }; + page.on("console", consoleListener); + try { + // Go to main page + await page.goto("http://0.0.0.0:8443/public/"); + // Wait for data to load + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + + // Steps to select dataset on dataset selector + for (const step of dataset.steps) { + await page.getByRole("button", { name: step }).click(); + } + + // Wait for data to load + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + + //applying dataset to the main map + await page.getByRole("button", { name: "Go" }).click(); + + //waiting for dataset to be loaded fully + await page.locator('img').first().waitFor({ state: 'visible', timeout: 30000 }); + // Open Edit Map Features + await page.locator(".MapTools > button:nth-child(2)").click(); + + // choosing area as upload type + //label position varies for SalishSeaCAst 3D Currents but same for the rest + if (dataset.name == "SalishSeaCast 3D Currents") { + await page.getByRole("combobox").nth(3).selectOption({ label: "Area" }); + } else { + await page.getByRole("dialog").getByRole("combobox").selectOption({ label: "Area" }); + } + + // Uploading CSV file + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "Upload CSV" }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(dataset.csvPath); + await page.waitForTimeout(2000); + + // Select checkbox + await page.getByRole("dialog").getByRole("checkbox").check(); + + + // Plot + await page.getByRole("button", { name: "Plot Selected Features" }).click(); + + // selecting subset variable + await page.getByRole("listbox").selectOption({ label: dataset.subset_var }); + + //compressing as zip file + await page.getByRole("checkbox", { name: "Compress as *.zip" }).click(); + + //Listen for download request + const downloadRequestPromise = page.waitForResponse( + (response) => { + const url = response.url(); + return ( + url.includes("/api/v2.0/subset") && + response.request().method() === "GET" + ); + }, + { timeout: 150000 } + ); + + //listening for download event + const downloadPromise = page.waitForEvent("download"); + + //clicking save file, should start downlaod + await page.getByRole("button", { name: "Save", exact: true }).click(); + + //check if back-end sends data + const downloadresponse= await downloadRequestPromise; + expect(downloadresponse.status()).toBe(200); + + // check if any error ocurred while downloading the file + if (consoleErrors.length > 0) { + throw new Error( + `Captured console errors for dataset "${ + dataset.name + }":\n- ${consoleErrors.join("\n- ")}` + ); + } + // download object created, if browser begins downloading + const download = await downloadPromise; + //giving a name to the file and ensuring download has started + //Note: file created will not be stored in the system and automatically cleaned up after test finishes + const suggestedFilename = download.suggestedFilename(); + expect(suggestedFilename).toBeTruthy(); + +}finally { + page.off("console", consoleListener); + } +} + +for (const dataset of datasets) { + + test(`dataset: ${dataset.name}`, async ({ page }) => { + // 4 minutes allocated per dataset + test.setTimeout(240000); + try { + await runPlotTest(page, dataset); + } catch (err) { + console.error(`Error in dataset "${dataset.name}": ${err.message}`); + throw err; + } + }); +} \ No newline at end of file diff --git a/Front-end_tests/t.ipynb b/Front-end_tests/t.ipynb new file mode 100644 index 000000000..da6147076 --- /dev/null +++ b/Front-end_tests/t.ipynb @@ -0,0 +1,198 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 4, + "id": "d0f5ab9c", + "metadata": {}, + "outputs": [ + { + "ename": "KeyError", + "evalue": "'DISPLAY'", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mKeyError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[4]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mpyautogui\u001b[39;00m\n\u001b[32m 2\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mtime\u001b[39;00m\n\u001b[32m 4\u001b[39m \u001b[38;5;28mprint\u001b[39m(\u001b[33m\"\u001b[39m\u001b[33mMouse mover started. Press Ctrl+C to stop.\u001b[39m\u001b[33m\"\u001b[39m)\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/onav-cloud/tools/miniforge3/envs/navigator/lib/python3.13/site-packages/pyautogui/__init__.py:246\u001b[39m\n\u001b[32m 242\u001b[39m screenshot = _couldNotImportPyScreeze\n\u001b[32m 245\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m246\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mmouseinfo\u001b[39;00m\n\u001b[32m 248\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mmouseInfo\u001b[39m():\n\u001b[32m 249\u001b[39m \u001b[38;5;250m \u001b[39m\u001b[33;03m\"\"\"\u001b[39;00m\n\u001b[32m 250\u001b[39m \u001b[33;03m Launches the MouseInfo app. This application provides mouse coordinate information which can be useful when\u001b[39;00m\n\u001b[32m 251\u001b[39m \u001b[33;03m planning GUI automation tasks. This function blocks until the application is closed.\u001b[39;00m\n\u001b[32m 252\u001b[39m \u001b[33;03m \"\"\"\u001b[39;00m\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/onav-cloud/tools/miniforge3/envs/navigator/lib/python3.13/site-packages/mouseinfo/__init__.py:223\u001b[39m\n\u001b[32m 220\u001b[39m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[32m 221\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m223\u001b[39m _display = Display(\u001b[43mos\u001b[49m\u001b[43m.\u001b[49m\u001b[43menviron\u001b[49m\u001b[43m[\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mDISPLAY\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m]\u001b[49m)\n\u001b[32m 225\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34m_linuxPosition\u001b[39m():\n\u001b[32m 226\u001b[39m coord = _display.screen().root.query_pointer()._data\n", + "\u001b[36mFile \u001b[39m\u001b[32m:717\u001b[39m, in \u001b[36m__getitem__\u001b[39m\u001b[34m(self, key)\u001b[39m\n", + "\u001b[31mKeyError\u001b[39m: 'DISPLAY'" + ] + } + ], + "source": [ + "import pyautogui\n", + "import time\n", + "\n", + "print(\"Mouse mover started. Press Ctrl+C to stop.\")\n", + "\n", + "while True:\n", + " # Get current mouse position\n", + " x, y = pyautogui.position()\n", + "\n", + " # Move mouse slightly and return to original spot\n", + " pyautogui.moveTo(x + 1, y + 1, duration=0.1)\n", + " pyautogui.moveTo(x, y, duration=0.1)\n", + "\n", + " # Wait 4 minutes before next move\n", + " time.sleep(240)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "5ffe7ed6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Collecting pyautogui\n", + " Downloading PyAutoGUI-0.9.54.tar.gz (61 kB)\n", + " Installing build dependencies ... \u001b[?25ldone\n", + "\u001b[?25h Getting requirements to build wheel ... \u001b[?25ldone\n", + "\u001b[?25h Preparing metadata (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25hCollecting python3-Xlib (from pyautogui)\n", + " Downloading python3-xlib-0.15.tar.gz (132 kB)\n", + " Installing build dependencies ... \u001b[?25ldone\n", + "\u001b[?25h Getting requirements to build wheel ... \u001b[?25ldone\n", + "\u001b[?25h Preparing metadata (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25hCollecting pymsgbox (from pyautogui)\n", + " Downloading pymsgbox-2.0.1-py3-none-any.whl.metadata (4.4 kB)\n", + "Collecting pytweening>=1.0.4 (from pyautogui)\n", + " Downloading pytweening-1.2.0.tar.gz (171 kB)\n", + " Installing build dependencies ... \u001b[?25ldone\n", + "\u001b[?25h Getting requirements to build wheel ... \u001b[?25ldone\n", + "\u001b[?25h Preparing metadata (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25hCollecting pyscreeze>=0.1.21 (from pyautogui)\n", + " Downloading pyscreeze-1.0.1.tar.gz (27 kB)\n", + " Installing build dependencies ... \u001b[?25ldone\n", + "\u001b[?25h Getting requirements to build wheel ... \u001b[?25ldone\n", + "\u001b[?25h Preparing metadata (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25hCollecting pygetwindow>=0.0.5 (from pyautogui)\n", + " Downloading PyGetWindow-0.0.9.tar.gz (9.7 kB)\n", + " Installing build dependencies ... \u001b[?25ldone\n", + "\u001b[?25h Getting requirements to build wheel ... \u001b[?25ldone\n", + "\u001b[?25h Preparing metadata (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25hCollecting mouseinfo (from pyautogui)\n", + " Downloading MouseInfo-0.1.3.tar.gz (10 kB)\n", + " Installing build dependencies ... \u001b[?25ldone\n", + "\u001b[?25h Getting requirements to build wheel ... \u001b[?25ldone\n", + "\u001b[?25h Preparing metadata (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25hCollecting pyrect (from pygetwindow>=0.0.5->pyautogui)\n", + " Downloading PyRect-0.2.0.tar.gz (17 kB)\n", + " Installing build dependencies ... \u001b[?25ldone\n", + "\u001b[?25h Getting requirements to build wheel ... \u001b[?25ldone\n", + "\u001b[?25h Preparing metadata (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25hCollecting pyperclip (from mouseinfo->pyautogui)\n", + " Downloading pyperclip-1.11.0-py3-none-any.whl.metadata (2.4 kB)\n", + "Downloading pymsgbox-2.0.1-py3-none-any.whl (10.0 kB)\n", + "Downloading pyperclip-1.11.0-py3-none-any.whl (11 kB)\n", + "Building wheels for collected packages: pyautogui, pygetwindow, pyscreeze, pytweening, mouseinfo, pyrect, python3-Xlib\n", + " Building wheel for pyautogui (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25h Created wheel for pyautogui: filename=pyautogui-0.9.54-py3-none-any.whl size=37684 sha256=3e6f8293e2a92420f626cdcd820a91ac0b7c9621f423ddb7d1c66dbf861bf68a\n", + " Stored in directory: /home/ubuntu/.cache/pip/wheels/9c/22/e7/c97f656d0790ef69feae4fcc7bbc1fc648f1b37879f52e7480\n", + " Building wheel for pygetwindow (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25h Created wheel for pygetwindow: filename=pygetwindow-0.0.9-py3-none-any.whl size=11118 sha256=fc4f6a3c6a61bd61a70d6198af59b10a71538c315323b53ac79a3fcc094a411f\n", + " Stored in directory: /home/ubuntu/.cache/pip/wheels/87/94/20/4e4ddd07e7276e44a38ac39d5b6c919d38ef27b9ebc5041e0f\n", + " Building wheel for pyscreeze (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25h Created wheel for pyscreeze: filename=pyscreeze-1.0.1-py3-none-any.whl size=14459 sha256=f1d67a706a32c641dea4e9506d2831e26e8ca660e4b37eec42020adf8befbcae\n", + " Stored in directory: /home/ubuntu/.cache/pip/wheels/1c/6e/d7/0acfbc5116e11006753649efc7cb40bb9df98990798a21960d\n", + " Building wheel for pytweening (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25h Created wheel for pytweening: filename=pytweening-1.2.0-py3-none-any.whl size=8113 sha256=845a66b4a550c1413f6fc84fdc803344da4b089f77147a682d0e70a4555c014f\n", + " Stored in directory: /home/ubuntu/.cache/pip/wheels/e7/89/f5/71248cbe0cc120d985c1976f8f2f31675069644c23ff03abfc\n", + " Building wheel for mouseinfo (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25h Created wheel for mouseinfo: filename=mouseinfo-0.1.3-py3-none-any.whl size=10951 sha256=97bccc94d1e40b408833f47f564d9fbcb76349ecd8d4d74bc614131962fc9cac\n", + " Stored in directory: /home/ubuntu/.cache/pip/wheels/61/5c/24/7d8ed078555f30e8f23fbc97f6361242a2cb4437ddd0384389\n", + " Building wheel for pyrect (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25h Created wheel for pyrect: filename=pyrect-0.2.0-py2.py3-none-any.whl size=11280 sha256=4e99ffe266923fea689279bebbcc256f903e5973acf3b697d1f5d7a652c806a4\n", + " Stored in directory: /home/ubuntu/.cache/pip/wheels/1e/84/e3/83782b4a92f3d4d5b7fff5c36bf8c31922719e0695ee854cf0\n", + " Building wheel for python3-Xlib (pyproject.toml) ... \u001b[?25ldone\n", + "\u001b[?25h Created wheel for python3-Xlib: filename=python3_xlib-0.15-py3-none-any.whl size=109549 sha256=966c726a23a6fa5eaa2101207694822a1a9044b42bf836fa7b57d727d8cb97ac\n", + " Stored in directory: /home/ubuntu/.cache/pip/wheels/96/6c/7d/73585a69a1ea6ae74a2618b549705cd2d7ea2b58e391e8fb81\n", + "Successfully built pyautogui pygetwindow pyscreeze pytweening mouseinfo pyrect python3-Xlib\n", + "Installing collected packages: pytweening, python3-Xlib, pyscreeze, pyrect, pyperclip, pymsgbox, pygetwindow, mouseinfo, pyautogui\n", + "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m9/9\u001b[0m [pyautogui]/9\u001b[0m [pygetwindow]\n", + "\u001b[1A\u001b[2KSuccessfully installed mouseinfo-0.1.3 pyautogui-0.9.54 pygetwindow-0.0.9 pymsgbox-2.0.1 pyperclip-1.11.0 pyrect-0.2.0 pyscreeze-1.0.1 python3-Xlib-0.15 pytweening-1.2.0\n", + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "pip install pyautogui\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "eddd0e9c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Windows auto key presser started. Press Ctrl+C to stop.\n" + ] + }, + { + "ename": "AttributeError", + "evalue": "module 'ctypes' has no attribute 'windll'", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mAttributeError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[6]\u001b[39m\u001b[32m, line 15\u001b[39m\n\u001b[32m 13\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m 14\u001b[39m \u001b[38;5;28;01mwhile\u001b[39;00m \u001b[38;5;28;01mTrue\u001b[39;00m:\n\u001b[32m---> \u001b[39m\u001b[32m15\u001b[39m \u001b[43mpress_key\u001b[49m\u001b[43m(\u001b[49m\u001b[43mVK_SHIFT\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 16\u001b[39m \u001b[38;5;28mprint\u001b[39m(\u001b[33m\"\u001b[39m\u001b[33mPressed Shift key.\u001b[39m\u001b[33m\"\u001b[39m)\n\u001b[32m 17\u001b[39m time.sleep(\u001b[32m240\u001b[39m) \u001b[38;5;66;03m# every 4 minutes\u001b[39;00m\n", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[6]\u001b[39m\u001b[32m, line 8\u001b[39m, in \u001b[36mpress_key\u001b[39m\u001b[34m(vk_code)\u001b[39m\n\u001b[32m 7\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mpress_key\u001b[39m(vk_code):\n\u001b[32m----> \u001b[39m\u001b[32m8\u001b[39m \u001b[43mctypes\u001b[49m\u001b[43m.\u001b[49m\u001b[43mwindll\u001b[49m.user32.keybd_event(vk_code, \u001b[32m0\u001b[39m, \u001b[32m0\u001b[39m, \u001b[32m0\u001b[39m)\n\u001b[32m 9\u001b[39m ctypes.windll.user32.keybd_event(vk_code, \u001b[32m0\u001b[39m, \u001b[32m2\u001b[39m, \u001b[32m0\u001b[39m)\n", + "\u001b[31mAttributeError\u001b[39m: module 'ctypes' has no attribute 'windll'" + ] + } + ], + "source": [ + "import ctypes\n", + "import time\n", + "\n", + "# Windows virtual-key code for Shift key\n", + "VK_SHIFT = 0x10\n", + "\n", + "def press_key(vk_code):\n", + " ctypes.windll.user32.keybd_event(vk_code, 0, 0, 0)\n", + " ctypes.windll.user32.keybd_event(vk_code, 0, 2, 0)\n", + "\n", + "print(\"Windows auto key presser started. Press Ctrl+C to stop.\")\n", + "\n", + "try:\n", + " while True:\n", + " press_key(VK_SHIFT)\n", + " print(\"Pressed Shift key.\")\n", + " time.sleep(240) # every 4 minutes\n", + "except KeyboardInterrupt:\n", + " print(\"Stopped.\")\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "navigator", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/Front-end_tests/test_data b/Front-end_tests/test_data new file mode 100644 index 000000000..e69de29bb diff --git a/Front-end_tests/test_datasets.json b/Front-end_tests/test_datasets.json new file mode 100644 index 000000000..5e911a9e2 --- /dev/null +++ b/Front-end_tests/test_datasets.json @@ -0,0 +1,126 @@ + +[ + { + "name": "GIOPS 10 Day Daily Mean", + "steps": ["Giops 10 Day Daily Mean"], + "compare_dataset":"RIOPS Forecast 3D - Polar", + "compare_steps":[ "RIOPS Forecast", "RIOPS Forecast 3D - Polar"], + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area NA 3.csv", + "subset_var": "Potential Temperature", + "arrows": "Water Velocity", + "contour": "none", + "arrow_box_position": "3", + "contour_box_position":"4", + "line_transect":"yes", + "line_coordinates":"flemish_cap", + "point_coordinates":["44.9855", "-50.9582"] + }, + { + "name": "RIOPS Forecast 3D - Polar", + "steps": [ + "GIOPS 10 Day Daily Mean", + "RIOPS Forecast", + "RIOPS Forecast 3D - Polar" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area NA 3.csv", + "subset_var": "Speed of Sound", + "arrows": "Water Velocity", + "contour": "none", + "arrow_box_position": "3", + "contour_box_position":"4", + "line_transect":"yes", + "line_Coordinates":"flemish_cap", + "point_coordinates":["44.9855", "-50.9582"] + }, + { + "name": "CCG RIOPS Forecast Surface -", + "steps": [ + "GIOPS 10 Day Daily Mean", + "RIOPS Forecast", + "CCG RIOPS Forecast Surface -" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area NA 3.csv", + "subset_var": "Potential Temperature", + "arrows": "Water Velocity", + "contour": "none", + "arrow_box_position": "2", + "contour_box_position":"3", + "line_transect":"no", + "line_Coordinates":"flemish_cap", + "point_coordinates":["44.9855", "-50.9582"] + }, + { + "name": "WCPS-GLS Great Lakes Coupled", + "steps": [ + "GIOPS 10 Day Daily Mean", + "WCPS Forecast", + "WCPS-GLS Great Lakes Coupled" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area Great Lakes 3.csv", + "subset_var": "Potential Temperature", + "arrows": "Water Velocity", + "contour": "none", + "arrow_box_position": "2", + "contour_box_position":"3", + "line_transect":"yes", + "line_Coordinates":"flemish_cap", + "point_coordinates":["44.9855", "-50.9582"] + }, + { + "name": "CIOPS Forecast East 3D -", + "steps": [ + "GIOPS 10 Day Daily Mean", + "CIOPS Forecast", + "CIOPS Forecast East 3D -" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area NA 3.csv", + "subset_var": "Potential Temperature", + "arrows": "none", + "contour": "Water Velocity", + "arrow_box_position": "3", + "contour_box_position": "4", + "line_transect":"yes", + "line_Coordinates":"flemish_cap", + "point_coordinates":["44.9855", "-50.9582"] + }, + { + "name": "Copernicus Marine Service Global Ocean 1/12 deg Physics Reanalysis (Monthly)", + "steps": [ + "GIOPS 10 Day Daily Mean", + "CMEMS", + "Copernicus Marine Service Global Ocean 1/12 deg Physics Reanalysis (Monthly)" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area NA 3.csv", + "subset_var": "Potential Temperature", + "arrows": "none", + "contour": "none", + "arrow_box_position": "2", + "contour_box_position": "3", + "line_transect":"yes", + "line_Coordinates":"flemish_cap", + "point_coordinates":["44.9855", "-50.9582"] + }, + { + "name": "SalishSeaCast 3D Currents", + "steps": [ + "GIOPS 10 Day Daily Mean", + "SalishSeaCast", + "SalishSeaCast 3D Currents" + ], + "compare_dataset":"GIOPS 10 Day Daily Mean", + "csvPath": "/home/ubuntu/onav-cloud/Ocean-Data-Map-Project/Front-end_tests/Total Area Salish 3.csv", + "subset_var": "Speed of Current", + "arrows": "none", + "contour": "Eastward Current", + "arrow_box_position": "2", + "contour_box_position": "3", + "line_transect":"yes", + "line_Coordinates":["-123.6","48.2","-123","48.3"], + "point_coordinates":["44.9855", "-50.9582"] + } +] \ No newline at end of file diff --git a/Ocean-Navigator-Manual b/Ocean-Navigator-Manual new file mode 160000 index 000000000..39b13c1ff --- /dev/null +++ b/Ocean-Navigator-Manual @@ -0,0 +1 @@ +Subproject commit 39b13c1ff6e1d789ff2a2e39e58609bea13c4c95 diff --git a/oceannavigator/frontend/public/index.html b/oceannavigator/frontend/public/index.html index 9f5d0dc55..d7c3de5c0 100644 --- a/oceannavigator/frontend/public/index.html +++ b/oceannavigator/frontend/public/index.html @@ -21,7 +21,7 @@ - +
diff --git a/oceannavigator/frontend/public/oceannavigator.js b/oceannavigator/frontend/public/oceannavigator.js index 24fef8255..b3238572a 100644 --- a/oceannavigator/frontend/public/oceannavigator.js +++ b/oceannavigator/frontend/public/oceannavigator.js @@ -9,2513 +9,8563 @@ /******/ (() => { // webpackBootstrap /******/ var __webpack_modules__ = ({ -/***/ "./node_modules/@restart/hooks/esm/useCallbackRef.js": -/*!***********************************************************!*\ - !*** ./node_modules/@restart/hooks/esm/useCallbackRef.js ***! - \***********************************************************/ +/***/ "./node_modules/@fortawesome/react-fontawesome/index.es.js": +/*!*****************************************************************!*\ + !*** ./node_modules/@fortawesome/react-fontawesome/index.es.js ***! + \*****************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (/* binding */ useCallbackRef)\n/* harmony export */ });\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);\n\n/**\n * A convenience hook around `useState` designed to be paired with\n * the component [callback ref](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) api.\n * Callback refs are useful over `useRef()` when you need to respond to the ref being set\n * instead of lazily accessing it in an effect.\n *\n * ```ts\n * const [element, attachRef] = useCallbackRef()\n *\n * useEffect(() => {\n * if (!element) return\n *\n * const calendar = new FullCalendar.Calendar(element)\n *\n * return () => {\n * calendar.destroy()\n * }\n * }, [element])\n *\n * return
\n * ```\n *\n * @category refs\n */\n\nfunction useCallbackRef() {\n return (0,react__WEBPACK_IMPORTED_MODULE_0__.useState)(null);\n}\n\n//# sourceURL=webpack://frontend/./node_modules/@restart/hooks/esm/useCallbackRef.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ FontAwesomeIcon: () => (/* binding */ FontAwesomeIcon)\n/* harmony export */ });\n/* harmony import */ var _fortawesome_fontawesome_svg_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @fortawesome/fontawesome-svg-core */ \"./node_modules/@fortawesome/fontawesome-svg-core/index.mjs\");\n/* harmony import */ var prop_types__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! prop-types */ \"./node_modules/prop-types/index.js\");\n/* harmony import */ var prop_types__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(prop_types__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_1__);\n\n\n\n\nfunction ownKeys(object, enumerableOnly) {\n var keys = Object.keys(object);\n\n if (Object.getOwnPropertySymbols) {\n var symbols = Object.getOwnPropertySymbols(object);\n enumerableOnly && (symbols = symbols.filter(function (sym) {\n return Object.getOwnPropertyDescriptor(object, sym).enumerable;\n })), keys.push.apply(keys, symbols);\n }\n\n return keys;\n}\n\nfunction _objectSpread2(target) {\n for (var i = 1; i < arguments.length; i++) {\n var source = null != arguments[i] ? arguments[i] : {};\n i % 2 ? ownKeys(Object(source), !0).forEach(function (key) {\n _defineProperty(target, key, source[key]);\n }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) {\n Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));\n });\n }\n\n return target;\n}\n\nfunction _typeof(obj) {\n \"@babel/helpers - typeof\";\n\n return _typeof = \"function\" == typeof Symbol && \"symbol\" == typeof Symbol.iterator ? function (obj) {\n return typeof obj;\n } : function (obj) {\n return obj && \"function\" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj;\n }, _typeof(obj);\n}\n\nfunction _defineProperty(obj, key, value) {\n if (key in obj) {\n Object.defineProperty(obj, key, {\n value: value,\n enumerable: true,\n configurable: true,\n writable: true\n });\n } else {\n obj[key] = value;\n }\n\n return obj;\n}\n\nfunction _objectWithoutPropertiesLoose(source, excluded) {\n if (source == null) return {};\n var target = {};\n var sourceKeys = Object.keys(source);\n var key, i;\n\n for (i = 0; i < sourceKeys.length; i++) {\n key = sourceKeys[i];\n if (excluded.indexOf(key) >= 0) continue;\n target[key] = source[key];\n }\n\n return target;\n}\n\nfunction _objectWithoutProperties(source, excluded) {\n if (source == null) return {};\n\n var target = _objectWithoutPropertiesLoose(source, excluded);\n\n var key, i;\n\n if (Object.getOwnPropertySymbols) {\n var sourceSymbolKeys = Object.getOwnPropertySymbols(source);\n\n for (i = 0; i < sourceSymbolKeys.length; i++) {\n key = sourceSymbolKeys[i];\n if (excluded.indexOf(key) >= 0) continue;\n if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue;\n target[key] = source[key];\n }\n }\n\n return target;\n}\n\nfunction _toConsumableArray(arr) {\n return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread();\n}\n\nfunction _arrayWithoutHoles(arr) {\n if (Array.isArray(arr)) return _arrayLikeToArray(arr);\n}\n\nfunction _iterableToArray(iter) {\n if (typeof Symbol !== \"undefined\" && iter[Symbol.iterator] != null || iter[\"@@iterator\"] != null) return Array.from(iter);\n}\n\nfunction _unsupportedIterableToArray(o, minLen) {\n if (!o) return;\n if (typeof o === \"string\") return _arrayLikeToArray(o, minLen);\n var n = Object.prototype.toString.call(o).slice(8, -1);\n if (n === \"Object\" && o.constructor) n = o.constructor.name;\n if (n === \"Map\" || n === \"Set\") return Array.from(o);\n if (n === \"Arguments\" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen);\n}\n\nfunction _arrayLikeToArray(arr, len) {\n if (len == null || len > arr.length) len = arr.length;\n\n for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i];\n\n return arr2;\n}\n\nfunction _nonIterableSpread() {\n throw new TypeError(\"Invalid attempt to spread non-iterable instance.\\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.\");\n}\n\n// Get CSS class list from a props object\nfunction classList(props) {\n var _classes;\n\n var beat = props.beat,\n fade = props.fade,\n beatFade = props.beatFade,\n bounce = props.bounce,\n shake = props.shake,\n flash = props.flash,\n spin = props.spin,\n spinPulse = props.spinPulse,\n spinReverse = props.spinReverse,\n pulse = props.pulse,\n fixedWidth = props.fixedWidth,\n inverse = props.inverse,\n border = props.border,\n listItem = props.listItem,\n flip = props.flip,\n size = props.size,\n rotation = props.rotation,\n pull = props.pull; // map of CSS class names to properties\n\n var classes = (_classes = {\n 'fa-beat': beat,\n 'fa-fade': fade,\n 'fa-beat-fade': beatFade,\n 'fa-bounce': bounce,\n 'fa-shake': shake,\n 'fa-flash': flash,\n 'fa-spin': spin,\n 'fa-spin-reverse': spinReverse,\n 'fa-spin-pulse': spinPulse,\n 'fa-pulse': pulse,\n 'fa-fw': fixedWidth,\n 'fa-inverse': inverse,\n 'fa-border': border,\n 'fa-li': listItem,\n 'fa-flip': flip === true,\n 'fa-flip-horizontal': flip === 'horizontal' || flip === 'both',\n 'fa-flip-vertical': flip === 'vertical' || flip === 'both'\n }, _defineProperty(_classes, \"fa-\".concat(size), typeof size !== 'undefined' && size !== null), _defineProperty(_classes, \"fa-rotate-\".concat(rotation), typeof rotation !== 'undefined' && rotation !== null && rotation !== 0), _defineProperty(_classes, \"fa-pull-\".concat(pull), typeof pull !== 'undefined' && pull !== null), _defineProperty(_classes, 'fa-swap-opacity', props.swapOpacity), _classes); // map over all the keys in the classes object\n // return an array of the keys where the value for the key is not null\n\n return Object.keys(classes).map(function (key) {\n return classes[key] ? key : null;\n }).filter(function (key) {\n return key;\n });\n}\n\n// Camelize taken from humps\n// humps is copyright © 2012+ Dom Christie\n// Released under the MIT license.\n// Performant way to determine if object coerces to a number\nfunction _isNumerical(obj) {\n obj = obj - 0; // eslint-disable-next-line no-self-compare\n\n return obj === obj;\n}\n\nfunction camelize(string) {\n if (_isNumerical(string)) {\n return string;\n } // eslint-disable-next-line no-useless-escape\n\n\n string = string.replace(/[\\-_\\s]+(.)?/g, function (match, chr) {\n return chr ? chr.toUpperCase() : '';\n }); // Ensure 1st char is always lowercase\n\n return string.substr(0, 1).toLowerCase() + string.substr(1);\n}\n\nvar _excluded = [\"style\"];\n\nfunction capitalize(val) {\n return val.charAt(0).toUpperCase() + val.slice(1);\n}\n\nfunction styleToObject(style) {\n return style.split(';').map(function (s) {\n return s.trim();\n }).filter(function (s) {\n return s;\n }).reduce(function (acc, pair) {\n var i = pair.indexOf(':');\n var prop = camelize(pair.slice(0, i));\n var value = pair.slice(i + 1).trim();\n prop.startsWith('webkit') ? acc[capitalize(prop)] = value : acc[prop] = value;\n return acc;\n }, {});\n}\n\nfunction convert(createElement, element) {\n var extraProps = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};\n\n if (typeof element === 'string') {\n return element;\n }\n\n var children = (element.children || []).map(function (child) {\n return convert(createElement, child);\n });\n /* eslint-disable dot-notation */\n\n var mixins = Object.keys(element.attributes || {}).reduce(function (acc, key) {\n var val = element.attributes[key];\n\n switch (key) {\n case 'class':\n acc.attrs['className'] = val;\n delete element.attributes['class'];\n break;\n\n case 'style':\n acc.attrs['style'] = styleToObject(val);\n break;\n\n default:\n if (key.indexOf('aria-') === 0 || key.indexOf('data-') === 0) {\n acc.attrs[key.toLowerCase()] = val;\n } else {\n acc.attrs[camelize(key)] = val;\n }\n\n }\n\n return acc;\n }, {\n attrs: {}\n });\n\n var _extraProps$style = extraProps.style,\n existingStyle = _extraProps$style === void 0 ? {} : _extraProps$style,\n remaining = _objectWithoutProperties(extraProps, _excluded);\n\n mixins.attrs['style'] = _objectSpread2(_objectSpread2({}, mixins.attrs['style']), existingStyle);\n /* eslint-enable */\n\n return createElement.apply(void 0, [element.tag, _objectSpread2(_objectSpread2({}, mixins.attrs), remaining)].concat(_toConsumableArray(children)));\n}\n\nvar PRODUCTION = false;\n\ntry {\n PRODUCTION = \"development\" === 'production';\n} catch (e) {}\n\nfunction log () {\n if (!PRODUCTION && console && typeof console.error === 'function') {\n var _console;\n\n (_console = console).error.apply(_console, arguments);\n }\n}\n\nfunction normalizeIconArgs(icon) {\n // this has everything that it needs to be rendered which means it was probably imported\n // directly from an icon svg package\n if (icon && _typeof(icon) === 'object' && icon.prefix && icon.iconName && icon.icon) {\n return icon;\n }\n\n if (_fortawesome_fontawesome_svg_core__WEBPACK_IMPORTED_MODULE_0__.parse.icon) {\n return _fortawesome_fontawesome_svg_core__WEBPACK_IMPORTED_MODULE_0__.parse.icon(icon);\n } // if the icon is null, there's nothing to do\n\n\n if (icon === null) {\n return null;\n } // if the icon is an object and has a prefix and an icon name, return it\n\n\n if (icon && _typeof(icon) === 'object' && icon.prefix && icon.iconName) {\n return icon;\n } // if it's an array with length of two\n\n\n if (Array.isArray(icon) && icon.length === 2) {\n // use the first item as prefix, second as icon name\n return {\n prefix: icon[0],\n iconName: icon[1]\n };\n } // if it's a string, use it as the icon name\n\n\n if (typeof icon === 'string') {\n return {\n prefix: 'fas',\n iconName: icon\n };\n }\n}\n\n// creates an object with a key of key\n// and a value of value\n// if certain conditions are met\nfunction objectWithKey(key, value) {\n // if the value is a non-empty array\n // or it's not an array but it is truthy\n // then create the object with the key and the value\n // if not, return an empty array\n return Array.isArray(value) && value.length > 0 || !Array.isArray(value) && value ? _defineProperty({}, key, value) : {};\n}\n\nvar defaultProps = {\n border: false,\n className: '',\n mask: null,\n maskId: null,\n fixedWidth: false,\n inverse: false,\n flip: false,\n icon: null,\n listItem: false,\n pull: null,\n pulse: false,\n rotation: null,\n size: null,\n spin: false,\n spinPulse: false,\n spinReverse: false,\n beat: false,\n fade: false,\n beatFade: false,\n bounce: false,\n shake: false,\n symbol: false,\n title: '',\n titleId: null,\n transform: null,\n swapOpacity: false\n};\nvar FontAwesomeIcon = /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_1___default().forwardRef(function (props, ref) {\n var allProps = _objectSpread2(_objectSpread2({}, defaultProps), props);\n\n var iconArgs = allProps.icon,\n maskArgs = allProps.mask,\n symbol = allProps.symbol,\n className = allProps.className,\n title = allProps.title,\n titleId = allProps.titleId,\n maskId = allProps.maskId;\n var iconLookup = normalizeIconArgs(iconArgs);\n var classes = objectWithKey('classes', [].concat(_toConsumableArray(classList(allProps)), _toConsumableArray((className || '').split(' '))));\n var transform = objectWithKey('transform', typeof allProps.transform === 'string' ? _fortawesome_fontawesome_svg_core__WEBPACK_IMPORTED_MODULE_0__.parse.transform(allProps.transform) : allProps.transform);\n var mask = objectWithKey('mask', normalizeIconArgs(maskArgs));\n var renderedIcon = (0,_fortawesome_fontawesome_svg_core__WEBPACK_IMPORTED_MODULE_0__.icon)(iconLookup, _objectSpread2(_objectSpread2(_objectSpread2(_objectSpread2({}, classes), transform), mask), {}, {\n symbol: symbol,\n title: title,\n titleId: titleId,\n maskId: maskId\n }));\n\n if (!renderedIcon) {\n log('Could not find icon', iconLookup);\n return null;\n }\n\n var abstract = renderedIcon.abstract;\n var extraProps = {\n ref: ref\n };\n Object.keys(allProps).forEach(function (key) {\n // eslint-disable-next-line no-prototype-builtins\n if (!defaultProps.hasOwnProperty(key)) {\n extraProps[key] = allProps[key];\n }\n });\n return convertCurry(abstract[0], extraProps);\n});\nFontAwesomeIcon.displayName = 'FontAwesomeIcon';\nFontAwesomeIcon.propTypes = {\n beat: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n border: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n beatFade: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n bounce: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n className: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string),\n fade: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n flash: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n mask: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOfType([(prop_types__WEBPACK_IMPORTED_MODULE_2___default().object), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().array), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string)]),\n maskId: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string),\n fixedWidth: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n inverse: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n flip: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOf([true, false, 'horizontal', 'vertical', 'both']),\n icon: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOfType([(prop_types__WEBPACK_IMPORTED_MODULE_2___default().object), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().array), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string)]),\n listItem: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n pull: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOf(['right', 'left']),\n pulse: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n rotation: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOf([0, 90, 180, 270]),\n shake: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n size: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOf(['2xs', 'xs', 'sm', 'lg', 'xl', '2xl', '1x', '2x', '3x', '4x', '5x', '6x', '7x', '8x', '9x', '10x']),\n spin: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n spinPulse: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n spinReverse: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool),\n symbol: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOfType([(prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string)]),\n title: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string),\n titleId: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().string),\n transform: prop_types__WEBPACK_IMPORTED_MODULE_2___default().oneOfType([(prop_types__WEBPACK_IMPORTED_MODULE_2___default().string), (prop_types__WEBPACK_IMPORTED_MODULE_2___default().object)]),\n swapOpacity: (prop_types__WEBPACK_IMPORTED_MODULE_2___default().bool)\n};\nvar convertCurry = convert.bind(null, (react__WEBPACK_IMPORTED_MODULE_1___default().createElement));\n\n\n\n\n//# sourceURL=webpack://frontend/./node_modules/@fortawesome/react-fontawesome/index.es.js?"); /***/ }), -/***/ "./node_modules/@restart/hooks/esm/useCommittedRef.js": -/*!************************************************************!*\ - !*** ./node_modules/@restart/hooks/esm/useCommittedRef.js ***! - \************************************************************/ +/***/ "./node_modules/@popperjs/core/lib/createPopper.js": +/*!*********************************************************!*\ + !*** ./node_modules/@popperjs/core/lib/createPopper.js ***! + \*********************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);\n\n/**\n * Creates a `Ref` whose value is updated in an effect, ensuring the most recent\n * value is the one rendered with. Generally only required for Concurrent mode usage\n * where previous work in `render()` may be discarded before being used.\n *\n * This is safe to access in an event handler.\n *\n * @param value The `Ref` value\n */\n\nfunction useCommittedRef(value) {\n var ref = (0,react__WEBPACK_IMPORTED_MODULE_0__.useRef)(value);\n (0,react__WEBPACK_IMPORTED_MODULE_0__.useEffect)(function () {\n ref.current = value;\n }, [value]);\n return ref;\n}\n\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (useCommittedRef);\n\n//# sourceURL=webpack://frontend/./node_modules/@restart/hooks/esm/useCommittedRef.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ createPopper: () => (/* binding */ createPopper),\n/* harmony export */ detectOverflow: () => (/* reexport safe */ _utils_detectOverflow_js__WEBPACK_IMPORTED_MODULE_8__[\"default\"]),\n/* harmony export */ popperGenerator: () => (/* binding */ popperGenerator)\n/* harmony export */ });\n/* harmony import */ var _dom_utils_getCompositeRect_js__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./dom-utils/getCompositeRect.js */ \"./node_modules/@popperjs/core/lib/dom-utils/getCompositeRect.js\");\n/* harmony import */ var _dom_utils_getLayoutRect_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! ./dom-utils/getLayoutRect.js */ \"./node_modules/@popperjs/core/lib/dom-utils/getLayoutRect.js\");\n/* harmony import */ var _dom_utils_listScrollParents_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./dom-utils/listScrollParents.js */ \"./node_modules/@popperjs/core/lib/dom-utils/listScrollParents.js\");\n/* harmony import */ var _dom_utils_getOffsetParent_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./dom-utils/getOffsetParent.js */ \"./node_modules/@popperjs/core/lib/dom-utils/getOffsetParent.js\");\n/* harmony import */ var _utils_orderModifiers_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./utils/orderModifiers.js */ \"./node_modules/@popperjs/core/lib/utils/orderModifiers.js\");\n/* harmony import */ var _utils_debounce_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./utils/debounce.js */ \"./node_modules/@popperjs/core/lib/utils/debounce.js\");\n/* harmony import */ var _utils_mergeByName_js__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./utils/mergeByName.js */ \"./node_modules/@popperjs/core/lib/utils/mergeByName.js\");\n/* harmony import */ var _utils_detectOverflow_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./utils/detectOverflow.js */ \"./node_modules/@popperjs/core/lib/utils/detectOverflow.js\");\n/* harmony import */ var _dom_utils_instanceOf_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./dom-utils/instanceOf.js */ \"./node_modules/@popperjs/core/lib/dom-utils/instanceOf.js\");\n\n\n\n\n\n\n\n\n\nvar DEFAULT_OPTIONS = {\n placement: 'bottom',\n modifiers: [],\n strategy: 'absolute'\n};\n\nfunction areValidElements() {\n for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {\n args[_key] = arguments[_key];\n }\n\n return !args.some(function (element) {\n return !(element && typeof element.getBoundingClientRect === 'function');\n });\n}\n\nfunction popperGenerator(generatorOptions) {\n if (generatorOptions === void 0) {\n generatorOptions = {};\n }\n\n var _generatorOptions = generatorOptions,\n _generatorOptions$def = _generatorOptions.defaultModifiers,\n defaultModifiers = _generatorOptions$def === void 0 ? [] : _generatorOptions$def,\n _generatorOptions$def2 = _generatorOptions.defaultOptions,\n defaultOptions = _generatorOptions$def2 === void 0 ? DEFAULT_OPTIONS : _generatorOptions$def2;\n return function createPopper(reference, popper, options) {\n if (options === void 0) {\n options = defaultOptions;\n }\n\n var state = {\n placement: 'bottom',\n orderedModifiers: [],\n options: Object.assign({}, DEFAULT_OPTIONS, defaultOptions),\n modifiersData: {},\n elements: {\n reference: reference,\n popper: popper\n },\n attributes: {},\n styles: {}\n };\n var effectCleanupFns = [];\n var isDestroyed = false;\n var instance = {\n state: state,\n setOptions: function setOptions(setOptionsAction) {\n var options = typeof setOptionsAction === 'function' ? setOptionsAction(state.options) : setOptionsAction;\n cleanupModifierEffects();\n state.options = Object.assign({}, defaultOptions, state.options, options);\n state.scrollParents = {\n reference: (0,_dom_utils_instanceOf_js__WEBPACK_IMPORTED_MODULE_0__.isElement)(reference) ? (0,_dom_utils_listScrollParents_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"])(reference) : reference.contextElement ? (0,_dom_utils_listScrollParents_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"])(reference.contextElement) : [],\n popper: (0,_dom_utils_listScrollParents_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"])(popper)\n }; // Orders the modifiers based on their dependencies and `phase`\n // properties\n\n var orderedModifiers = (0,_utils_orderModifiers_js__WEBPACK_IMPORTED_MODULE_2__[\"default\"])((0,_utils_mergeByName_js__WEBPACK_IMPORTED_MODULE_3__[\"default\"])([].concat(defaultModifiers, state.options.modifiers))); // Strip out disabled modifiers\n\n state.orderedModifiers = orderedModifiers.filter(function (m) {\n return m.enabled;\n });\n runModifierEffects();\n return instance.update();\n },\n // Sync update – it will always be executed, even if not necessary. This\n // is useful for low frequency updates where sync behavior simplifies the\n // logic.\n // For high frequency updates (e.g. `resize` and `scroll` events), always\n // prefer the async Popper#update method\n forceUpdate: function forceUpdate() {\n if (isDestroyed) {\n return;\n }\n\n var _state$elements = state.elements,\n reference = _state$elements.reference,\n popper = _state$elements.popper; // Don't proceed if `reference` or `popper` are not valid elements\n // anymore\n\n if (!areValidElements(reference, popper)) {\n return;\n } // Store the reference and popper rects to be read by modifiers\n\n\n state.rects = {\n reference: (0,_dom_utils_getCompositeRect_js__WEBPACK_IMPORTED_MODULE_4__[\"default\"])(reference, (0,_dom_utils_getOffsetParent_js__WEBPACK_IMPORTED_MODULE_5__[\"default\"])(popper), state.options.strategy === 'fixed'),\n popper: (0,_dom_utils_getLayoutRect_js__WEBPACK_IMPORTED_MODULE_6__[\"default\"])(popper)\n }; // Modifiers have the ability to reset the current update cycle. The\n // most common use case for this is the `flip` modifier changing the\n // placement, which then needs to re-run all the modifiers, because the\n // logic was previously ran for the previous placement and is therefore\n // stale/incorrect\n\n state.reset = false;\n state.placement = state.options.placement; // On each update cycle, the `modifiersData` property for each modifier\n // is filled with the initial data specified by the modifier. This means\n // it doesn't persist and is fresh on each update.\n // To ensure persistent data, use `${name}#persistent`\n\n state.orderedModifiers.forEach(function (modifier) {\n return state.modifiersData[modifier.name] = Object.assign({}, modifier.data);\n });\n\n for (var index = 0; index < state.orderedModifiers.length; index++) {\n if (state.reset === true) {\n state.reset = false;\n index = -1;\n continue;\n }\n\n var _state$orderedModifie = state.orderedModifiers[index],\n fn = _state$orderedModifie.fn,\n _state$orderedModifie2 = _state$orderedModifie.options,\n _options = _state$orderedModifie2 === void 0 ? {} : _state$orderedModifie2,\n name = _state$orderedModifie.name;\n\n if (typeof fn === 'function') {\n state = fn({\n state: state,\n options: _options,\n name: name,\n instance: instance\n }) || state;\n }\n }\n },\n // Async and optimistically optimized update – it will not be executed if\n // not necessary (debounced to run at most once-per-tick)\n update: (0,_utils_debounce_js__WEBPACK_IMPORTED_MODULE_7__[\"default\"])(function () {\n return new Promise(function (resolve) {\n instance.forceUpdate();\n resolve(state);\n });\n }),\n destroy: function destroy() {\n cleanupModifierEffects();\n isDestroyed = true;\n }\n };\n\n if (!areValidElements(reference, popper)) {\n return instance;\n }\n\n instance.setOptions(options).then(function (state) {\n if (!isDestroyed && options.onFirstUpdate) {\n options.onFirstUpdate(state);\n }\n }); // Modifiers have the ability to execute arbitrary code before the first\n // update cycle runs. They will be executed in the same order as the update\n // cycle. This is useful when a modifier adds some persistent data that\n // other modifiers need to use, but the modifier is run after the dependent\n // one.\n\n function runModifierEffects() {\n state.orderedModifiers.forEach(function (_ref) {\n var name = _ref.name,\n _ref$options = _ref.options,\n options = _ref$options === void 0 ? {} : _ref$options,\n effect = _ref.effect;\n\n if (typeof effect === 'function') {\n var cleanupFn = effect({\n state: state,\n name: name,\n instance: instance,\n options: options\n });\n\n var noopFn = function noopFn() {};\n\n effectCleanupFns.push(cleanupFn || noopFn);\n }\n });\n }\n\n function cleanupModifierEffects() {\n effectCleanupFns.forEach(function (fn) {\n return fn();\n });\n effectCleanupFns = [];\n }\n\n return instance;\n };\n}\nvar createPopper = /*#__PURE__*/popperGenerator(); // eslint-disable-next-line import/no-unused-modules\n\n\n\n//# sourceURL=webpack://frontend/./node_modules/@popperjs/core/lib/createPopper.js?"); /***/ }), -/***/ "./node_modules/@restart/hooks/esm/useEventCallback.js": -/*!*************************************************************!*\ - !*** ./node_modules/@restart/hooks/esm/useEventCallback.js ***! - \*************************************************************/ +/***/ "./node_modules/@popperjs/core/lib/dom-utils/contains.js": +/*!***************************************************************!*\ + !*** ./node_modules/@popperjs/core/lib/dom-utils/contains.js ***! + \***************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (/* binding */ useEventCallback)\n/* harmony export */ });\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var _useCommittedRef__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./useCommittedRef */ \"./node_modules/@restart/hooks/esm/useCommittedRef.js\");\n\n\nfunction useEventCallback(fn) {\n var ref = (0,_useCommittedRef__WEBPACK_IMPORTED_MODULE_1__[\"default\"])(fn);\n return (0,react__WEBPACK_IMPORTED_MODULE_0__.useCallback)(function () {\n return ref.current && ref.current.apply(ref, arguments);\n }, [ref]);\n}\n\n//# sourceURL=webpack://frontend/./node_modules/@restart/hooks/esm/useEventCallback.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (/* binding */ contains)\n/* harmony export */ });\n/* harmony import */ var _instanceOf_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./instanceOf.js */ \"./node_modules/@popperjs/core/lib/dom-utils/instanceOf.js\");\n\nfunction contains(parent, child) {\n var rootNode = child.getRootNode && child.getRootNode(); // First, attempt with faster native method\n\n if (parent.contains(child)) {\n return true;\n } // then fallback to custom implementation with Shadow DOM support\n else if (rootNode && (0,_instanceOf_js__WEBPACK_IMPORTED_MODULE_0__.isShadowRoot)(rootNode)) {\n var next = child;\n\n do {\n if (next && parent.isSameNode(next)) {\n return true;\n } // $FlowFixMe[prop-missing]: need a better way to handle this...\n\n\n next = next.parentNode || next.host;\n } while (next);\n } // Give up, the result is false\n\n\n return false;\n}\n\n//# sourceURL=webpack://frontend/./node_modules/@popperjs/core/lib/dom-utils/contains.js?"); /***/ }), -/***/ "./node_modules/@restart/hooks/esm/useMergedRefs.js": -/*!**********************************************************!*\ - !*** ./node_modules/@restart/hooks/esm/useMergedRefs.js ***! - \**********************************************************/ +/***/ "./node_modules/@popperjs/core/lib/dom-utils/getBoundingClientRect.js": +/*!****************************************************************************!*\ + !*** ./node_modules/@popperjs/core/lib/dom-utils/getBoundingClientRect.js ***! + \****************************************************************************/ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__),\n/* harmony export */ \"mergeRefs\": () => (/* binding */ mergeRefs)\n/* harmony export */ });\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ \"./node_modules/react/index.js\");\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);\n\n\nvar toFnRef = function toFnRef(ref) {\n return !ref || typeof ref === 'function' ? ref : function (value) {\n ref.current = value;\n };\n};\n\nfunction mergeRefs(refA, refB) {\n var a = toFnRef(refA);\n var b = toFnRef(refB);\n return function (value) {\n if (a) a(value);\n if (b) b(value);\n };\n}\n/**\n * Create and returns a single callback ref composed from two other Refs.\n *\n * ```tsx\n * const Button = React.forwardRef((props, ref) => {\n * const [element, attachRef] = useCallbackRef();\n * const mergedRef = useMergedRefs(ref, attachRef);\n *\n * return