diff --git a/src/components/ConditionalAccordionItem.svelte b/src/components/ConditionalAccordionItem.svelte new file mode 100644 index 0000000..1b85e92 --- /dev/null +++ b/src/components/ConditionalAccordionItem.svelte @@ -0,0 +1,32 @@ + + +{#if !disabled} + + {#snippet header()}{title}{/snippet} + {@render children()} + +{:else} +

+
+ {title} +
+

+{/if} diff --git a/src/components/ProjectSetup/LoadFile.svelte b/src/components/ProjectSetup/LoadFile.svelte index 2caa1c6..9d644ca 100644 --- a/src/components/ProjectSetup/LoadFile.svelte +++ b/src/components/ProjectSetup/LoadFile.svelte @@ -1,7 +1,7 @@ diff --git a/src/components/ProjectSetup/ProjectSetupDialog.svelte b/src/components/ProjectSetup/ProjectSetupDialog.svelte index 4ed966c..2ecd411 100644 --- a/src/components/ProjectSetup/ProjectSetupDialog.svelte +++ b/src/components/ProjectSetup/ProjectSetupDialog.svelte @@ -1,10 +1,17 @@ + + +
+ + A single country + + + Multiple countries + +
+{#if countrySettingsType.value === CountrySettingsType.SingleCountry} + + store.countrySettings.countryIdColumn || "", (v) => (store.countrySettings.countryIdColumn = v) + } + /> + + + + + + diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 63e0490..650ed64 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,13 +1,15 @@ import type { ClientInit } from "@sveltejs/kit"; import { store } from "./store.svelte"; import type { AppConfig } from "./types"; +import { doFetch } from "$lib/utils"; export const init: ClientInit = async () => { - // TODO: consider fetch wrapper with generic error handling - const response = await fetch("./easymap.config.json"); - if (!response.ok) { - store.errors.fetch = "Error fetching app config"; - } else { - store.appConfig = (await response.json()) as AppConfig; + const configRes = await doFetch("./easymap.config.json"); + if (!configRes.ok) return; + store.appConfig = configRes.json as AppConfig; + const { groutUrl, groutDataset } = store.appConfig; + const countriesRes = await doFetch(`${groutUrl}/region-metadata/${groutDataset}/admin0`); + if (countriesRes.ok) { + store.countries = countriesRes.json.data; } }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..c8e50e2 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,16 @@ +import { store } from "../store.svelte"; + +export const doFetch = async (url: string) => { + const response = await fetch(url); + if (!response.ok) { + store.errors.fetch = `Error fetching from ${url}`; + } + const json = await response.json(); + return { ok: response.ok, json }; +}; + +// convert an enum with string values into a list of items for a select item +// NB this will not work for enum with numeric values as entries are duplicated +export const stringEnumToSelectItems = (senum: object) => { + return Object.entries(senum).map((e) => ({ name: e[0], value: e[1] })); +}; diff --git a/src/store.svelte.ts b/src/store.svelte.ts index 75b44d3..a1dc82a 100644 --- a/src/store.svelte.ts +++ b/src/store.svelte.ts @@ -1,5 +1,5 @@ -import type { AppConfig } from "./types"; -import { ProjectDialog } from "./types"; +import type { AppConfig, CountrySettings, MultiCountrySettings, RegionSettings } from "./types"; +import { CountrySettingsType, ProjectDialog, RegionAdminLevel, RegionIdType } from "./types"; import type { DataFile } from "$lib/DataFile"; import { SvelteSet } from "svelte/reactivity"; @@ -8,13 +8,29 @@ export interface StoreProblems { loadFile?: string; } +export interface LatLng { + lat: number; + lng: number; +} + export interface Store { errors: StoreProblems; warnings: StoreProblems; appConfig: null | AppConfig; + countries: { + id: string; + name: string; + bounds: { + min: LatLng; + max: LatLng; + }; + }[]; openProjectDialog: null | ProjectDialog; enabledProjectDialogs: Set; dataFile: null | DataFile; + countrySettingsType: CountrySettingsType; + countrySettings: CountrySettings; + regionSettings: RegionSettings; } export const store: Store = $state({ @@ -23,5 +39,33 @@ export const store: Store = $state({ appConfig: null, openProjectDialog: ProjectDialog.Setup, enabledProjectDialogs: new SvelteSet([ProjectDialog.Setup]), - dataFile: null + dataFile: null, + countrySettingsType: CountrySettingsType.SingleCountry, + countrySettings: { + countryISO: null + }, + regionSettings: { + adminLevel: RegionAdminLevel.Admin1, + regionIdType: RegionIdType.Name, + regionIdColumn: null + } }); + +// When the user uploads a new data file having already chosen settings in the context of a previous +// file, we will try to retain any column references from the previous file, but will remove any which +// are no longer possible because the new file does not have those columns +export const updateSettingsForNewDataFile = () => { + const newColumns = store.dataFile?.columns; + if (store.countrySettingsType == CountrySettingsType.MultiCountry) { + const multiCountrySettings = store.countrySettings as MultiCountrySettings; + const countryIdCol = multiCountrySettings.countryIdColumn; + if (countryIdCol && !newColumns?.includes(countryIdCol)) { + multiCountrySettings.countryIdColumn = null; + } + } + + const regionIdCol = store.regionSettings.regionIdColumn; + if (regionIdCol && !newColumns?.includes(regionIdCol)) { + store.regionSettings.regionIdColumn = null; + } +}; diff --git a/src/types.ts b/src/types.ts index f360e92..8f55ac8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,52 @@ export interface AppConfig { appTitle: string; + groutUrl: string; + groutDataset: string; } export enum ProjectDialog { Setup, Download } + +export enum ProjectSetupAccItem { + OpenFile, + SetAreas +} + +export enum CountrySettingsType { + SingleCountry, + MultiCountry +} + +export enum CountryIdType { + ISO3 = "iso3", + Name = "name" +} + +export interface SingleCountrySettings { + countryISO: string | null; +} + +export interface MultiCountrySettings { + countryIdType: CountryIdType; + countryIdColumn: string | null; +} + +export type CountrySettings = SingleCountrySettings | MultiCountrySettings; + +export enum RegionAdminLevel { + Admin1 = "admin1", + Admin2 = "admin2" +} + +export enum RegionIdType { + GADM = "gadm", + Name = "name" +} + +export interface RegionSettings { + adminLevel: RegionAdminLevel; + regionIdType: RegionIdType; + regionIdColumn: string | null; +} diff --git a/static/easymap.config.json b/static/easymap.config.json index 88085c1..b1c58d4 100644 --- a/static/easymap.config.json +++ b/static/easymap.config.json @@ -1,3 +1,5 @@ { - "appTitle": "EasyMap" + "appTitle": "EasyMap", + "groutUrl": "https://mrcdata.dide.ic.ac.uk/grout", + "groutDataset": "gadm41" } diff --git a/tests/e2e/projectSetup.test.ts b/tests/e2e/projectSetup.test.ts index 54b8e0a..249673c 100644 --- a/tests/e2e/projectSetup.test.ts +++ b/tests/e2e/projectSetup.test.ts @@ -51,4 +51,59 @@ test.describe("Project setup", () => { await loadFile(page, "no_example_data.csv"); await expect(page.getByText(/File load error: No data rows in file/)).toBeVisible(); }); + + test("can set area config", async ({ page }) => { + await loadFile(page, "multi_country_name.csv"); + // Click Next button + const nextButton = page.getByText("Next"); + await expect(nextButton).toBeEnabled(); + await nextButton.click(); + // Can see default is single country + await expect(page.getByLabel(/A single country/)).toBeChecked(); + const multiCountries = await page.getByLabel(/Multiple countries/); + await expect(multiCountries).not.toBeChecked(); + // Can see country select values (and not controls for multi country) + const countrySelect = page.getByLabel("Country", { exact: true }); + const countryOptions = countrySelect.getByRole("option"); + await expect(countryOptions.nth(0)).toHaveText("Choose option ..."); + await expect(countryOptions.nth(1)).toHaveText("Aruba"); + const countryColumnSelect = page.getByLabel(/Country column/); + await expect(countryColumnSelect).not.toBeVisible(); + const countryIdTypeSelect = page.getByLabel(/Countries are identified by/); + await expect(countryIdTypeSelect).not.toBeVisible(); + + // Can switch to multi country and see country and column and country id type controls and options + await multiCountries.click(); + await expect(countrySelect).not.toBeVisible(); + await expect(countryColumnSelect).toBeVisible(); + const countryColumnOptions = countryColumnSelect.getByRole("option"); + await expect(countryColumnOptions).toHaveCount(5); + await expect(countryColumnOptions.nth(0)).toHaveText("Choose option ..."); + await expect(countryColumnOptions.nth(1)).toHaveText("Country"); + await expect(countryColumnOptions.nth(4)).toHaveText("Incidence"); + await expect(countryIdTypeSelect).toBeVisible(); + await expect(countryIdTypeSelect).toHaveValue("name"); + const countryIdTypeOptions = countryIdTypeSelect.getByRole("option"); + await expect(countryIdTypeOptions.nth(1)).toHaveText("ISO3"); + await expect(countryIdTypeOptions.nth(2)).toHaveText("Name"); + + // Can see Region controls and options + const adminLevelSelect = page.getByLabel(/Region admin level/); + await expect(adminLevelSelect).toBeVisible(); + await expect(adminLevelSelect).toHaveValue("admin1"); + const adminLevelOptions = adminLevelSelect.getByRole("option"); + await expect(adminLevelOptions.nth(1)).toHaveText("Admin1"); + await expect(adminLevelOptions.nth(2)).toHaveText("Admin2"); + const regionColumnSelect = page.getByLabel(/Region column/); + await expect(regionColumnSelect).toBeVisible(); + const regionColumnOptions = regionColumnSelect.getByRole("option"); + await expect(regionColumnOptions.nth(1)).toHaveText("Country"); + await expect(regionColumnOptions.nth(4)).toHaveText("Incidence"); + const regionIdTypeSelect = page.getByLabel(/Regions are identified by/); + await expect(regionIdTypeSelect).toBeVisible(); + await expect(regionIdTypeSelect).toHaveValue("name"); + const regionIdTypeOptions = regionIdTypeSelect.getByRole("option"); + await expect(regionIdTypeOptions.nth(1)).toHaveText("GADM"); + await expect(regionIdTypeOptions.nth(2)).toHaveText("Name"); + }); }); diff --git a/tests/testFiles/multi_country_iso.csv b/tests/testFiles/multi_country_iso.csv new file mode 100644 index 0000000..d73e2b2 --- /dev/null +++ b/tests/testFiles/multi_country_iso.csv @@ -0,0 +1,18 @@ +ISO,ADM1,Prevalence,Incidence +GBR,GBR.1_1,0.231,0.012 +GBR,GBR.2_1,0.154,0.022 +GBR,GBR.3_1,0.243,0.014 +GBR,GBR.4_1,0.126,0.087 +FRA,FRA.1_1,0.113,0.032 +FRA,FRA.2_1,0.205,0.012 +FRA,FRA.3_1,0.276,0.062 +FRA,FRA.4_1,0.112,0.001 +FRA,FRA.5_1,0.097,0.032 +FRA,FRA.6_1,0.215,0.087 +FRA,FRA.7_1,0.226,0.053 +FRA,FRA.8_1,0.159,0.022 +FRA,FRA.9_1,0.298,0.019 +FRA,FRA.10_1,0.102,0.033 +FRA,FRA.11_1,0.079,0.017 +FRA,FRA.12_1,0.091,0.023 +FRA,FRA.13_1,0.189,0.061 diff --git a/tests/testFiles/multi_country_name.csv b/tests/testFiles/multi_country_name.csv new file mode 100644 index 0000000..4797365 --- /dev/null +++ b/tests/testFiles/multi_country_name.csv @@ -0,0 +1,18 @@ +Country,Region,Prevalence,Incidence +United Kingdom,England,0.231,0.012 +United Kingdom,Northern Ireland,0.154,0.022 +United Kingdom,Scotland,0.243,0.014 +United Kingdom,Wales,0.126,0.087 +France,Auvergne-Rhône-Alpes,0.113,0.032 +France,Bourgogne-Franche-Comté,0.205,0.012 +France,Bretagne,0.276,0.062 +France,Centre-Val de Loire,0.112,0.001 +France,Corse,0.097,0.032 +France,Grand Est,0.215,0.087 +France,Hauts-de-France,0.226,0.053 +France,Île-de-France,0.159,0.022 +France,Normandie,0.298,0.019 +France,Nouvelle-Aquitaine,0.102,0.033 +France,Occitanie,0.079,0.017 +France,Pays de la Loire,0.091,0.023 +France,Provence-Alpes-Côte d'Azur,0.189,0.061 diff --git a/tests/unit/components/ConditionalAccordionItem.svelte.test.ts b/tests/unit/components/ConditionalAccordionItem.svelte.test.ts new file mode 100644 index 0000000..ecd22a2 --- /dev/null +++ b/tests/unit/components/ConditionalAccordionItem.svelte.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import { createRawSnippet } from "svelte"; +import ConditionalAccordionItem from "../../../src/components/ConditionalAccordionItem.svelte"; + +describe("Conditional Accordion Item", () => { + const children = createRawSnippet(() => { + return { + render: () => "
CAI content
", + setup: () => {} + }; + }); + const title = "Test Header"; + test("renders as expected when open", () => { + render(ConditionalAccordionItem, { + props: { open: true, disabled: false, title, children } + }); + expect(screen.getByRole("button")).toBeVisible(); // expand buton exists + expect(screen.getByText(title)).toBeVisible(); + expect(screen.getByText(/CAI content/)).toBeVisible(); + }); + + test("renders as expected when not open", () => { + render(ConditionalAccordionItem, { + props: { open: false, disabled: false, title, children } + }); + expect(screen.getByRole("button")).toBeVisible(); // expand buton exists + expect(screen.getByText(title)).toBeVisible(); + expect(screen.queryByText(/CAI content/)).toBeNull(); + }); + + test("renders as expected when disabled", () => { + render(ConditionalAccordionItem, { + props: { open: false, disabled: true, title, children } + }); + expect(screen.queryByRole("button")).toBeNull(); // expand buton does not exist + expect(screen.getByText(title)).toBeVisible(); + expect(screen.queryByText(/CAI content/)).toBeNull(); + }); +}); diff --git a/tests/unit/components/ProjectSetup/LoadFile.svelte.test.ts b/tests/unit/components/ProjectSetup/LoadFile.svelte.test.ts index 1545e6c..f970266 100644 --- a/tests/unit/components/ProjectSetup/LoadFile.svelte.test.ts +++ b/tests/unit/components/ProjectSetup/LoadFile.svelte.test.ts @@ -4,8 +4,10 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; import { getTestFile } from "../../utils"; import type { DataFile } from "$lib/DataFile"; -let { mockStore } = vi.hoisted(() => ({ - mockStore: {} +/* eslint-disable-next-line prefer-const */ +let { mockStore, mockUpdateSettingsForNewDataFile } = vi.hoisted(() => ({ + mockStore: {}, + mockUpdateSettingsForNewDataFile: vi.fn() })); describe("LoadFile", () => { @@ -16,7 +18,10 @@ describe("LoadFile", () => { warnings: {}, dataFile: null }; - return { store: mockStore }; + return { + store: mockStore, + updateSettingsForNewDataFile: mockUpdateSettingsForNewDataFile + }; }); }); @@ -74,6 +79,15 @@ describe("LoadFile", () => { }); }); + test("calls updateSettingsForNewDataFile", async () => { + render(LoadFile); + const input = getInput(); + uploadTestFile(input, "example_data.xls"); + await waitFor(() => { + expect(mockUpdateSettingsForNewDataFile).toHaveBeenCalled(); + }); + }); + test("displays load file warning", async () => { render(LoadFile); const input = getInput(); diff --git a/tests/unit/components/ProjectSetup/ProjectSetupDialog.svelte.test.ts b/tests/unit/components/ProjectSetup/ProjectSetupDialog.svelte.test.ts new file mode 100644 index 0000000..f3fad41 --- /dev/null +++ b/tests/unit/components/ProjectSetup/ProjectSetupDialog.svelte.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import ProjectSetupDialog from "../../../../src/components/ProjectSetup/ProjectSetupDialog.svelte"; +import { store } from "../../../../src/store.svelte"; + +// Workaround jsdom not fully implementing animate and dialogs +Element.prototype.animate ??= vi.fn().mockReturnValue({ + finished: Promise.resolve(), + cancel: vi.fn(), + startTime: null, + currentTime: null +}); +HTMLDialogElement.prototype.show = vi.fn().mockImplementation(function (this: HTMLDialogElement) { + this.open = true; +}); +HTMLDialogElement.prototype.close = vi.fn().mockImplementation(function (this: HTMLDialogElement) { + this.open = false; +}); + +const mockDataFile = { + columns: ["mockCol"] +} as any; + +describe("ProjectSetupDialog", () => { + beforeEach(() => { + vi.mock("../../../../src/store.svelte.ts", () => { + return { + store: { + dataFile: null, + errors: {}, + warnings: {}, + openProjectDialog: 0, //ProjectDialog.Setup - can't import type while mocking + enabledProjectDialogs: new Set([0]), + countrySettings: { + countryISO: null + }, + regionSettings: { + adminLevel: "Admin1", + regionIdType: "Name", + regionIdColumn: null + } + } + }; + }); + }); + + const getNextButton = () => { + return screen.getByRole("button", { name: /Next/ }); + }; + + test("renders as expected before data file is loaded", () => { + render(ProjectSetupDialog); + expect(screen.getByText(/1. Open file/)).toBeVisible(); + expect(getNextButton()).not.toBeEnabled(); + expect(screen.getByText(/2. Set areas/)).toBeVisible(); + expect(screen.getAllByRole("button").length).toBe(3); // don't expect Set areas expand button to be rendered + }); + + test("renders as expected after data file is loaded", () => { + store.dataFile = mockDataFile; + render(ProjectSetupDialog); + expect(getNextButton()).toBeEnabled(); + expect(screen.getByText(/2. Set areas/)).toBeVisible(); + expect(screen.getAllByRole("button").length).toBe(4); // expect Set areas expand button to be rendered + }); + + test("click Next button in 'Open file' opens 'Set areas'", async () => { + store.dataFile = mockDataFile; + render(ProjectSetupDialog); + expect(screen.queryByText(/The file contains areas for/)).toBeNull(); + const nextButton = getNextButton(); + nextButton.click(); + await waitFor(() => { + expect(screen.getByText(/The file contains areas for/)).toBeVisible(); + }); + }); +}); diff --git a/tests/unit/components/ProjectSetup/SetAreas.svelte.test.ts b/tests/unit/components/ProjectSetup/SetAreas.svelte.test.ts new file mode 100644 index 0000000..4e80a7f --- /dev/null +++ b/tests/unit/components/ProjectSetup/SetAreas.svelte.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { within } from "@testing-library/dom"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { + CountryIdType, + CountrySettingsType, + MultiCountrySettings, + RegionAdminLevel, + RegionIdType, + SingleCountrySettings +} from "../../../../src/types"; +import SetAreas from "../../../../src/components/ProjectSetup/SetAreas.svelte"; +import { store } from "../../../../src/store.svelte.ts"; + +describe("Set Areas", () => { + beforeEach(() => { + vi.mock("../../../../src/store.svelte.ts", () => { + const testStore = $state({ + countrySettingsType: CountrySettingsType.SingleCountry, + countrySettings: { + countryISO: null + }, + regionSettings: { + adminLevel: RegionAdminLevel.Admin1, + regionIdType: RegionIdType.Name, + regionIdColumn: null + }, + countries: [ + { name: "Country 1", id: "C1" }, + { name: "Country 2", id: "C2" } + ], + dataFile: { + columns: ["Column 1", "Column 2"] + } + }); + return { + store: testStore + }; + }); + }); + + const setup = () => { + render(SetAreas); + return userEvent.setup(); + }; + + const expectSelectOptions = (select: HTMLSelectElement, expectedOptions: { name: string; value: string }[]) => { + const allOptions = within(select).getAllByRole("option"); + expect(allOptions.length).toBe(expectedOptions.length + 1); + expect(allOptions[0]).toHaveValue(""); // empty unselected option + expectedOptions.forEach((option, idx) => { + expect(allOptions[idx + 1]).toHaveValue(option.value); + expect(allOptions[idx + 1].innerHTML).toBe(option.name); + }); + }; + + test("binds country settings type correctly", async () => { + const user = setup(); + const singleCountryRadio = screen.getByLabelText(/A single country/); + const multiCountryRadio = screen.getByLabelText(/Multiple countries/); + expect(singleCountryRadio.checked).toBe(true); + expect(multiCountryRadio.checked).toBe(false); + await user.click(multiCountryRadio); + expect(singleCountryRadio.checked).toBe(false); + expect(multiCountryRadio.checked).toBe(true); + expect(store.countrySettingsType).toBe(CountrySettingsType.MultiCountry); + // Hides single country controls and show multi country controls + expect(screen.queryByLabelText("Country")).toBeNull(); + expect(screen.getByLabelText(/Country column/)).toBeVisible(); + expect(screen.getByLabelText(/Countries are identified by/)).toBeVisible(); + + await user.click(singleCountryRadio); + expect(singleCountryRadio.checked).toBe(true); + expect(multiCountryRadio.checked).toBe(false); + expect(store.countrySettingsType).toBe(CountrySettingsType.SingleCountry); + // Hides multi country controls and shows single country controls + expect(screen.getByLabelText("Country")).toBeVisible(); + expect(screen.queryByLabelText(/Country column/)).toBeNull(); + expect(screen.queryByLabelText(/Countries are identified by/)).toBeNull(); + }); + + test("binds country correctly", async () => { + const user = setup(); + const countrySelect = screen.getByLabelText("Country"); + expectSelectOptions(countrySelect, [ + { name: "Country 1", value: "C1" }, + { name: "Country 2", value: "C2" } + ]); + await user.selectOptions(countrySelect, countrySelect.options[2]); + expect((store.countrySettings as SingleCountrySettings).countryISO).toBe("C2"); + }); + + test("binds country id column correctly", async () => { + const user = setup(); + const multiCountryRadio = screen.getByLabelText(/Multiple countries/); + await user.click(multiCountryRadio); + const countryIdColSelect = screen.getByLabelText(/Country column/); + expectSelectOptions(countryIdColSelect, [ + { name: "Column 1", value: "Column 1" }, + { name: "Column 2", value: "Column 2" } + ]); + await user.selectOptions(countryIdColSelect, countryIdColSelect.options[1]); + expect((store.countrySettings as MultiCountrySettings).countryIdColumn).toBe("Column 1"); + }); + + test("binds country id type correctly", async () => { + const user = setup(); + const multiCountryRadio = screen.getByLabelText(/Multiple countries/); + await user.click(multiCountryRadio); + const countryIdTypeSelect = screen.getByLabelText(/Countries are identified by/); + expectSelectOptions(countryIdTypeSelect, [ + { name: "ISO3", value: "iso3" }, + { name: "Name", value: "name" } + ]); + await user.selectOptions(countryIdTypeSelect, countryIdTypeSelect.options[2]); + expect((store.countrySettings as MultiCountrySettings).countryIdType).toBe(CountryIdType.Name); + }); + + test("binds admin level correctly", async () => { + const user = setup(); + const adminLevelSelect = screen.getByLabelText(/Region admin level/); + expectSelectOptions(adminLevelSelect, [ + { name: "Admin1", value: "admin1" }, + { name: "Admin2", value: "admin2" } + ]); + await user.selectOptions(adminLevelSelect, adminLevelSelect.options[2]); + expect(store.regionSettings.adminLevel).toBe(RegionAdminLevel.Admin2); + }); + + test("binds region id column correctly", async () => { + const user = setup(); + const regionIdColSelect = screen.getByLabelText(/Region column/); + expectSelectOptions(regionIdColSelect, [ + { name: "Column 1", value: "Column 1" }, + { name: "Column 2", value: "Column 2" } + ]); + await user.selectOptions(regionIdColSelect, regionIdColSelect.options[2]); + expect(store.regionSettings.regionIdColumn).toBe("Column 2"); + }); + + test("binds region id type correctly", async () => { + const user = setup(); + const regionIdTypeSelect = screen.getByLabelText(/Regions are identified by/); + expectSelectOptions(regionIdTypeSelect, [ + { name: "GADM", value: "gadm" }, + { name: "Name", value: "name" } + ]); + await user.selectOptions(regionIdTypeSelect, regionIdTypeSelect.options[2]); + expect(store.regionSettings.regionIdType).toBe(RegionIdType.Name); + }); +}); diff --git a/tests/unit/hooks.client.test.ts b/tests/unit/hooks.client.test.ts index 43fb1d4..757ad13 100644 --- a/tests/unit/hooks.client.test.ts +++ b/tests/unit/hooks.client.test.ts @@ -2,17 +2,30 @@ import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { init } from "../../src/hooks.client"; +import { groutSuccessResponse } from "./utils"; + +const mockConfig = { + appTitle: "Test Title", + groutUrl: "https://mock-grout", + groutDataset: "mock-dataset" +}; + +const mockCountries = [{ id: "AFG", name: "Afghanistan" }]; +const configHandler = http.get("./easymap.config.json", () => { + return HttpResponse.json(mockConfig); +}); -const mockConfig = { appTitle: "Test Title" }; const server = setupServer( - http.get("./easymap.config.json", () => { - return HttpResponse.json(mockConfig); + configHandler, + http.get("https://mock-grout/region-metadata/mock-dataset/admin0", () => { + return HttpResponse.json(groutSuccessResponse(mockCountries)); }) ); -const { mockSetError, mockSetConfig } = vi.hoisted(() => ({ +const { mockSetError, mockSetConfig, mockSetCountries } = vi.hoisted(() => ({ mockSetError: vi.fn(), - mockSetConfig: vi.fn() + mockSetConfig: vi.fn(), + mockSetCountries: vi.fn() })); vi.mock("../../src/store.svelte.ts", () => { @@ -20,7 +33,8 @@ vi.mock("../../src/store.svelte.ts", () => { errors: {} }; Object.defineProperty(mockStore.errors, "fetch", { set: mockSetError }); - Object.defineProperty(mockStore, "appConfig", { set: mockSetConfig }); + Object.defineProperty(mockStore, "appConfig", { set: mockSetConfig, get: () => mockConfig }); + Object.defineProperty(mockStore, "countries", { set: mockSetCountries }); return { store: mockStore }; }); @@ -34,10 +48,12 @@ describe("Client hooks", () => { await server.close(); }); - test("init fetches config and updates store", async () => { + test("init fetches config and countries and updates store", async () => { await init(); expect(mockSetConfig).toHaveBeenCalledTimes(1); expect(mockSetConfig.mock.calls[0][0]).toStrictEqual(mockConfig); + expect(mockSetCountries).toHaveBeenCalledTimes(1); + expect(mockSetCountries.mock.calls[0][0]).toStrictEqual(mockCountries); expect(mockSetError).not.toHaveBeenCalled(); }); @@ -49,6 +65,22 @@ describe("Client hooks", () => { ); await init(); expect(mockSetConfig).not.toHaveBeenCalled(); - expect(mockSetError).toHaveBeenCalledWith("Error fetching app config"); + expect(mockSetError).toHaveBeenCalledWith("Error fetching from ./easymap.config.json"); + expect(mockSetCountries).not.toHaveBeenCalled(); + }); + + test("init does not set countries if fetch fails", async () => { + server.use( + configHandler, + http.get("https://mock-grout/region-metadata/mock-dataset/admin0", () => { + return HttpResponse("oh no", { status: 500 }); + }) + ); + await init(); + expect(mockSetConfig).toHaveBeenCalled(); + expect(mockSetError).toHaveBeenCalledWith( + "Error fetching from https://mock-grout/region-metadata/mock-dataset/admin0" + ); + expect(mockSetCountries).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/store.svelte.test.ts b/tests/unit/store.svelte.test.ts new file mode 100644 index 0000000..5661e1e --- /dev/null +++ b/tests/unit/store.svelte.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, test, expect } from "vitest"; +import { store, updateSettingsForNewDataFile } from "../../src/store.svelte"; +import { CountryIdType, CountrySettingsType, MultiCountrySettings } from "../../src/types"; + +describe("updateSettingsForNewDataFile", () => { + beforeEach(() => { + store.countrySettingsType = CountrySettingsType.MultiCountry; + store.countrySettings = { + countryIdType: CountryIdType.ISO3, + countryIdColumn: "oldCol3" + }; + store.regionSettings.regionIdColumn = "oldCol1"; + }); + + test("retains columns settings when they are still present in file", () => { + store.dataFile = { + columns: ["oldCol1", "oldCol2", "oldCol3"] + } as any; + updateSettingsForNewDataFile(); + expect((store.countrySettings as MultiCountrySettings).countryIdColumn).toBe("oldCol3"); + expect(store.regionSettings.regionIdColumn).toBe("oldCol1"); + }); + + test("resets column settings when they are not present in file", () => { + store.dataFile = { + columns: ["newCol1", "newCol2", "newCol3"] + } as any; + updateSettingsForNewDataFile(); + expect((store.countrySettings as MultiCountrySettings).countryIdColumn).toBeNull(); + expect(store.regionSettings.regionIdColumn).toBeNull(); + }); +}); diff --git a/tests/unit/utils.ts b/tests/unit/utils.ts index 2b21645..bb2be1e 100644 --- a/tests/unit/utils.ts +++ b/tests/unit/utils.ts @@ -23,3 +23,11 @@ export const getTestFile = (fileName: string): File => { }; return file; }; + +export const groutSuccessResponse = (data: Array | object | string) => { + return { + status: "success", + errors: null, + data + }; +};