From aa9b5750ea92f8a15fdf740bf7df9edc0409301e Mon Sep 17 00:00:00 2001 From: Emma Date: Mon, 26 Jan 2026 15:49:11 +0000 Subject: [PATCH 01/13] add types and conditional accordion item --- .../ConditionalAccordionItem.svelte | 25 ++++++++++++ .../ProjectSetup/ProjectSetupDialog.svelte | 7 ++++ src/components/ProjectSetup/SetAreas.svelte | 3 ++ src/store.svelte.ts | 21 +++++++--- src/types.ts | 39 +++++++++++++++++++ 5 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 src/components/ConditionalAccordionItem.svelte create mode 100644 src/components/ProjectSetup/SetAreas.svelte diff --git a/src/components/ConditionalAccordionItem.svelte b/src/components/ConditionalAccordionItem.svelte new file mode 100644 index 0000000..853d51b --- /dev/null +++ b/src/components/ConditionalAccordionItem.svelte @@ -0,0 +1,25 @@ + + +{#if !disabled} + + {#snippet header()}{header}{/snippet} + {@render children()} + +{:else} +

+
+ {header} +
+

+{/if} \ No newline at end of file diff --git a/src/components/ProjectSetup/ProjectSetupDialog.svelte b/src/components/ProjectSetup/ProjectSetupDialog.svelte index 4ed966c..a4d5d12 100644 --- a/src/components/ProjectSetup/ProjectSetupDialog.svelte +++ b/src/components/ProjectSetup/ProjectSetupDialog.svelte @@ -3,6 +3,8 @@ import { store } from "../../store.svelte"; import { ProjectDialog } from "../../types"; import LoadFile from "./LoadFile.svelte"; + import SetAreas from './SetAreas.svelte'; + import ConditionalAccordionItem from '../ConditionalAccordionItem.svelte'; let isOpen = $derived(store.openProjectDialog == ProjectDialog.Setup); const handleClose = () => { @@ -23,5 +25,10 @@ {#snippet header()}1. Open file{/snippet} + + + + + diff --git a/src/components/ProjectSetup/SetAreas.svelte b/src/components/ProjectSetup/SetAreas.svelte new file mode 100644 index 0000000..a31cff5 --- /dev/null +++ b/src/components/ProjectSetup/SetAreas.svelte @@ -0,0 +1,3 @@ +
+ This is where setting areas will go +
\ No newline at end of file diff --git a/src/store.svelte.ts b/src/store.svelte.ts index 75b44d3..95c1a3d 100644 --- a/src/store.svelte.ts +++ b/src/store.svelte.ts @@ -1,7 +1,7 @@ -import type { AppConfig } from "./types"; -import { ProjectDialog } from "./types"; -import type { DataFile } from "$lib/DataFile"; -import { SvelteSet } from "svelte/reactivity"; +import type { AppConfig, CountrySettings, RegionSettings } from './types'; +import { CountrySettingsType, ProjectDialog, RegionAdminLevel, RegionIdType } from './types'; +import type { DataFile } from '$lib/DataFile'; +import { SvelteSet } from 'svelte/reactivity'; export interface StoreProblems { fetch?: string; @@ -15,6 +15,8 @@ export interface Store { openProjectDialog: null | ProjectDialog; enabledProjectDialogs: Set; dataFile: null | DataFile; + countrySettings: CountrySettings; + regionSettings: RegionSettings; } export const store: Store = $state({ @@ -23,5 +25,14 @@ export const store: Store = $state({ appConfig: null, openProjectDialog: ProjectDialog.Setup, enabledProjectDialogs: new SvelteSet([ProjectDialog.Setup]), - dataFile: null + dataFile: null, + countrySettings: { + settingsType: CountrySettingsType.SingleCountry, + countryISO: null + }, + regionSettings: { + adminLevel: RegionAdminLevel.Admin1, + regionIdType: RegionIdType.Name, + regionIdColumn: null + } }); diff --git a/src/types.ts b/src/types.ts index f360e92..bab809a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,3 +6,42 @@ export enum ProjectDialog { Setup, Download } + +export enum CountrySettingsType { + SingleCountry, + MultiCountry +} + +export enum CountryIdType { + ISO3, + Name +} + +export interface SingleCountrySettings { + settingsType: CountrySettingsType.SingleCountry, + countryISO: string | null +} + +export interface MultiCountrySettings { + settingsType: CountrySettingsType.MultiCountry, + countryIdType: CountryIdType, + countryIdColumn: string | null +} + +export type CountrySettings = SingleCountrySettings | MultiCountrySettings; + +export enum RegionAdminLevel { + Admin1, + Admin2 +} + +export enum RegionIdType { + GADM, + Name +} + +export interface RegionSettings { + adminLevel: RegionAdminLevel, + regionIdType: RegionIdType, + regionIdColumn: string | null +} \ No newline at end of file From 17883da5f960b667c9cfd7e34c1ad8eb46ac1283 Mon Sep 17 00:00:00 2001 From: Emma Date: Mon, 26 Jan 2026 17:25:02 +0000 Subject: [PATCH 02/13] add controls for multi country files --- src/components/ProjectSetup/SetAreas.svelte | 73 ++++++++++++++++++++- src/store.svelte.ts | 3 +- src/types.ts | 2 - 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/components/ProjectSetup/SetAreas.svelte b/src/components/ProjectSetup/SetAreas.svelte index a31cff5..ea39635 100644 --- a/src/components/ProjectSetup/SetAreas.svelte +++ b/src/components/ProjectSetup/SetAreas.svelte @@ -1,3 +1,70 @@ -
- This is where setting areas will go -
\ No newline at end of file + + +
+ + A single country + + + Multiple countries + +
+{#if countrySettingsType.value === CountrySettingsType.SingleCountry} +single country +{:else} + + +{/if} + +
{JSON.stringify(store.countrySettings)}
diff --git a/src/store.svelte.ts b/src/store.svelte.ts index 95c1a3d..8d9f7ee 100644 --- a/src/store.svelte.ts +++ b/src/store.svelte.ts @@ -15,6 +15,7 @@ export interface Store { openProjectDialog: null | ProjectDialog; enabledProjectDialogs: Set; dataFile: null | DataFile; + countrySettingsType: CountrySettingsType; countrySettings: CountrySettings; regionSettings: RegionSettings; } @@ -26,8 +27,8 @@ export const store: Store = $state({ openProjectDialog: ProjectDialog.Setup, enabledProjectDialogs: new SvelteSet([ProjectDialog.Setup]), dataFile: null, + countrySettingsType: CountrySettingsType.SingleCountry, countrySettings: { - settingsType: CountrySettingsType.SingleCountry, countryISO: null }, regionSettings: { diff --git a/src/types.ts b/src/types.ts index bab809a..f06228f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,12 +18,10 @@ export enum CountryIdType { } export interface SingleCountrySettings { - settingsType: CountrySettingsType.SingleCountry, countryISO: string | null } export interface MultiCountrySettings { - settingsType: CountrySettingsType.MultiCountry, countryIdType: CountryIdType, countryIdColumn: string | null } From 057db5bff59aaa3da3a169686d6c3ba926a700ce Mon Sep 17 00:00:00 2001 From: Emma Date: Tue, 27 Jan 2026 17:18:03 +0000 Subject: [PATCH 03/13] fetch countries from grout on start up --- src/hooks.client.ts | 16 +++++++++------- src/lib/utils.ts | 10 ++++++++++ src/store.svelte.ts | 13 +++++++++++++ src/types.ts | 2 ++ static/easymap.config.json | 4 +++- 5 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 src/lib/utils.ts diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 63e0490..e468e59 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; + let configRes = await doFetch("./easymap.config.json"); + if (!configRes.ok) return + store.appConfig = configRes.json as AppConfig; + const {groutUrl, groutDataset} = store.appConfig; + let 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..fc42280 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,10 @@ +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} +} \ No newline at end of file diff --git a/src/store.svelte.ts b/src/store.svelte.ts index 8d9f7ee..d84ae2e 100644 --- a/src/store.svelte.ts +++ b/src/store.svelte.ts @@ -8,10 +8,23 @@ 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; diff --git a/src/types.ts b/src/types.ts index f06228f..a4afa3c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,7 @@ export interface AppConfig { appTitle: string; + groutUrl: string; + groutDataset: string; } export enum ProjectDialog { 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" } From ee3f2680b2c993b76c92441650bb565081d9b31b Mon Sep 17 00:00:00 2001 From: Emma Date: Tue, 27 Jan 2026 17:24:25 +0000 Subject: [PATCH 04/13] user can select country --- src/components/ProjectSetup/SetAreas.svelte | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/ProjectSetup/SetAreas.svelte b/src/components/ProjectSetup/SetAreas.svelte index ea39635..542a363 100644 --- a/src/components/ProjectSetup/SetAreas.svelte +++ b/src/components/ProjectSetup/SetAreas.svelte @@ -13,12 +13,10 @@ const countryIdTypeItems = [ { name: "ISO3", value: CountryIdType.ISO3 } ]; -// TODO: replace this with country metadata fetched from grout - run locally for dev -const countryItems = [ - {name: "France", value: "FRA"}, - {name: "Thailand", value: "THA"}, - {name: "Venezuela", value: "VEN"} -]; +const countryItems = $derived(store.countries.map((c) => ({ + name: c.name, + value: c.id +}))); // When select new country settings type, need to refresh settings object to default for type let countrySettingsType = { @@ -58,7 +56,8 @@ let countrySettingsType = { {#if countrySettingsType.value === CountrySettingsType.SingleCountry} -single country + + @@ -67,4 +66,4 @@ single country - - {/if} + + + + + - - -
{JSON.stringify(store.countrySettings)}
-
{JSON.stringify(store.regionSettings)}
diff --git a/src/store.svelte.ts b/src/store.svelte.ts index d84ae2e..6d7cffe 100644 --- a/src/store.svelte.ts +++ b/src/store.svelte.ts @@ -1,4 +1,4 @@ -import type { AppConfig, CountrySettings, RegionSettings } 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'; @@ -50,3 +50,22 @@ export const store: Store = $state({ 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/testData/multi_country_iso.csv b/testData/multi_country_iso.csv new file mode 100644 index 0000000..d73e2b2 --- /dev/null +++ b/testData/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/testData/multi_country_name.csv b/testData/multi_country_name.csv new file mode 100644 index 0000000..4797365 --- /dev/null +++ b/testData/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 From 5f76cb16a8d18143f0a0edb2fb76321424a53e1a Mon Sep 17 00:00:00 2001 From: Emma Date: Thu, 29 Jan 2026 16:28:50 +0000 Subject: [PATCH 08/13] some unit tests --- .../ProjectSetup/ProjectSetupDialog.svelte | 2 +- .../ProjectSetup/LoadFile.svelte.test.ts | 19 ++++- .../ProjectSetupDialog.svelte.test.ts | 77 +++++++++++++++++++ tests/unit/hooks.client.test.ts | 46 +++++++++-- tests/unit/utils.ts | 8 ++ 5 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 tests/unit/components/ProjectSetup/ProjectSetupDialog.svelte.test.ts diff --git a/src/components/ProjectSetup/ProjectSetupDialog.svelte b/src/components/ProjectSetup/ProjectSetupDialog.svelte index 05c305e..41735e4 100644 --- a/src/components/ProjectSetup/ProjectSetupDialog.svelte +++ b/src/components/ProjectSetup/ProjectSetupDialog.svelte @@ -33,7 +33,7 @@ - + diff --git a/tests/unit/components/ProjectSetup/LoadFile.svelte.test.ts b/tests/unit/components/ProjectSetup/LoadFile.svelte.test.ts index 1545e6c..055257c 100644 --- a/tests/unit/components/ProjectSetup/LoadFile.svelte.test.ts +++ b/tests/unit/components/ProjectSetup/LoadFile.svelte.test.ts @@ -4,8 +4,9 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; import { getTestFile } from "../../utils"; import type { DataFile } from "$lib/DataFile"; -let { mockStore } = vi.hoisted(() => ({ - mockStore: {} +let { mockStore, mockUpdateSettingsForNewDataFile } = vi.hoisted(() => ({ + mockStore: {}, + mockUpdateSettingsForNewDataFile: vi.fn() })); describe("LoadFile", () => { @@ -16,7 +17,10 @@ describe("LoadFile", () => { warnings: {}, dataFile: null }; - return { store: mockStore }; + return { + store: mockStore, + updateSettingsForNewDataFile: mockUpdateSettingsForNewDataFile + }; }); }); @@ -74,6 +78,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..8a01e6a --- /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(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/hooks.client.test.ts b/tests/unit/hooks.client.test.ts index 43fb1d4..62e4383 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,20 @@ 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/utils.ts b/tests/unit/utils.ts index 2b21645..2f1a285 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: any) => { + return { + status: "success", + errors: null, + data + }; +}; \ No newline at end of file From d753b6d4a165127514331daaed4d34726a7df1a9 Mon Sep 17 00:00:00 2001 From: Emma Date: Thu, 29 Jan 2026 16:56:23 +0000 Subject: [PATCH 09/13] ConditionalAccordionItem tests --- .../ConditionalAccordionItem.svelte.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/unit/components/ConditionalAccordionItem.svelte.test.ts diff --git a/tests/unit/components/ConditionalAccordionItem.svelte.test.ts b/tests/unit/components/ConditionalAccordionItem.svelte.test.ts new file mode 100644 index 0000000..4be500e --- /dev/null +++ b/tests/unit/components/ConditionalAccordionItem.svelte.test.ts @@ -0,0 +1,43 @@ +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 header = "Test Header" + test("renders as expected when open", () => { + render(ConditionalAccordionItem, { + props: { open: true, disabled: false, header, children } + } + ); + expect(screen.getByRole("button")).toBeVisible(); // expand buton exists + expect(screen.getByText(header)).toBeVisible(); + expect(screen.getByText(/CAI content/)).toBeVisible(); + }); + + test("renders as expected when not open", () => { + render(ConditionalAccordionItem, { + props: { open: false, disabled: false, header, children } + } + ); + expect(screen.getByRole("button")).toBeVisible(); // expand buton exists + expect(screen.getByText(header)).toBeVisible(); + expect(screen.queryByText(/CAI content/)).toBeNull(); + }); + + test("renders as expected when disabled", () => { + render(ConditionalAccordionItem, { + props: { open: false, disabled: true, header, children } + } + ); + expect(screen.queryByRole("button")).toBeNull(); // expand buton does not exist + expect(screen.getByText(header)).toBeVisible(); + expect(screen.queryByText(/CAI content/)).toBeNull(); + }); +}); \ No newline at end of file From b7e856f282149f19b7468751d75073386df347af Mon Sep 17 00:00:00 2001 From: Emma Date: Fri, 30 Jan 2026 16:34:39 +0000 Subject: [PATCH 10/13] more unit tests --- src/components/ProjectSetup/SetAreas.svelte | 2 +- .../ProjectSetup/SetAreas.svelte.test.ts | 145 ++++++++++++++++++ tests/unit/store.svelte.test.ts | 32 ++++ 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 tests/unit/components/ProjectSetup/SetAreas.svelte.test.ts create mode 100644 tests/unit/store.svelte.test.ts diff --git a/src/components/ProjectSetup/SetAreas.svelte b/src/components/ProjectSetup/SetAreas.svelte index 5fe038c..da57e0a 100644 --- a/src/components/ProjectSetup/SetAreas.svelte +++ b/src/components/ProjectSetup/SetAreas.svelte @@ -63,7 +63,7 @@ const regionIdTypeItems = stringEnumToSelectItems(RegionIdType); {#if countrySettingsType.value === CountrySettingsType.SingleCountry} - + + + @@ -76,7 +80,9 @@ const regionIdTypeItems = stringEnumToSelectItems(RegionIdType); + From 85993520ca495fe29034b946028e28174b79150a Mon Sep 17 00:00:00 2001 From: Emma Date: Tue, 3 Feb 2026 09:34:07 +0000 Subject: [PATCH 12/13] add e2e test --- tests/e2e/projectSetup.test.ts | 55 +++++++++++++++++++ .../testFiles}/multi_country_iso.csv | 0 .../testFiles}/multi_country_name.csv | 0 3 files changed, 55 insertions(+) rename {testData => tests/testFiles}/multi_country_iso.csv (100%) rename {testData => tests/testFiles}/multi_country_name.csv (100%) 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/testData/multi_country_iso.csv b/tests/testFiles/multi_country_iso.csv similarity index 100% rename from testData/multi_country_iso.csv rename to tests/testFiles/multi_country_iso.csv diff --git a/testData/multi_country_name.csv b/tests/testFiles/multi_country_name.csv similarity index 100% rename from testData/multi_country_name.csv rename to tests/testFiles/multi_country_name.csv From 61ec5ebd528f6744d0b1aa0c80ac486ad0587a1b Mon Sep 17 00:00:00 2001 From: Emma Date: Tue, 3 Feb 2026 10:37:39 +0000 Subject: [PATCH 13/13] lint and format --- .../ConditionalAccordionItem.svelte | 42 +-- src/components/ProjectSetup/LoadFile.svelte | 2 +- .../ProjectSetup/ProjectSetupDialog.svelte | 18 +- src/components/ProjectSetup/SetAreas.svelte | 158 +++++----- src/hooks.client.ts | 12 +- src/lib/utils.ts | 22 +- src/store.svelte.ts | 24 +- src/types.ts | 14 +- .../ConditionalAccordionItem.svelte.test.ts | 69 +++-- .../ProjectSetup/LoadFile.svelte.test.ts | 1 + .../ProjectSetupDialog.svelte.test.ts | 116 ++++---- .../ProjectSetup/SetAreas.svelte.test.ts | 269 +++++++++--------- tests/unit/hooks.client.test.ts | 18 +- tests/unit/store.svelte.test.ts | 56 ++-- tests/unit/utils.ts | 4 +- 15 files changed, 432 insertions(+), 393 deletions(-) diff --git a/src/components/ConditionalAccordionItem.svelte b/src/components/ConditionalAccordionItem.svelte index 5a0ff03..1b85e92 100644 --- a/src/components/ConditionalAccordionItem.svelte +++ b/src/components/ConditionalAccordionItem.svelte @@ -1,28 +1,32 @@ {#if !disabled} - - {#snippet header()}{header}{/snippet} - {@render children()} + + {#snippet header()}{title}{/snippet} + {@render children()} {:else} -

-
- {header} -
+

+
+ {title} +

-{/if} \ No newline at end of file +{/if} diff --git a/src/components/ProjectSetup/LoadFile.svelte b/src/components/ProjectSetup/LoadFile.svelte index c982f68..9d644ca 100644 --- a/src/components/ProjectSetup/LoadFile.svelte +++ b/src/components/ProjectSetup/LoadFile.svelte @@ -1,7 +1,7 @@ +
- - A single country - - - Multiple countries - + + A single country + + + Multiple countries +
{#if countrySettingsType.value === CountrySettingsType.SingleCountry} - - + store.countrySettings.countryISO || + "" /* Need to map null to empty string to show placeholder option */, + (v) => (store.countrySettings.countryISO = v) + } + /> {:else} - - store.countrySettings.countryIdColumn || "", (v) => (store.countrySettings.countryIdColumn = v) + } + /> - - {/if} store.regionSettings.regionIdColumn || "", - (v) => store.regionSettings.regionIdColumn = v} /> + diff --git a/src/hooks.client.ts b/src/hooks.client.ts index e468e59..650ed64 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,15 +1,15 @@ import type { ClientInit } from "@sveltejs/kit"; import { store } from "./store.svelte"; import type { AppConfig } from "./types"; -import { doFetch } from '$lib/utils'; +import { doFetch } from "$lib/utils"; export const init: ClientInit = async () => { - let configRes = await doFetch("./easymap.config.json"); - if (!configRes.ok) return + const configRes = await doFetch("./easymap.config.json"); + if (!configRes.ok) return; store.appConfig = configRes.json as AppConfig; - const {groutUrl, groutDataset} = store.appConfig; - let countriesRes = await doFetch(`${groutUrl}/region-metadata/${groutDataset}/admin0`); + 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 index dcbefa6..c8e50e2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,16 +1,16 @@ -import { store } from '../store.svelte'; +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} -} + 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]})); -} \ No newline at end of file +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 6d7cffe..a1dc82a 100644 --- a/src/store.svelte.ts +++ b/src/store.svelte.ts @@ -1,7 +1,7 @@ -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'; +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"; export interface StoreProblems { fetch?: string; @@ -9,8 +9,8 @@ export interface StoreProblems { } export interface LatLng { - lat: number, - lng: number + lat: number; + lng: number; } export interface Store { @@ -18,12 +18,12 @@ export interface Store { warnings: StoreProblems; appConfig: null | AppConfig; countries: { - id: string, - name: string, + id: string; + name: string; bounds: { - min: LatLng, - max: LatLng - } + min: LatLng; + max: LatLng; + }; }[]; openProjectDialog: null | ProjectDialog; enabledProjectDialogs: Set; @@ -68,4 +68,4 @@ export const updateSettingsForNewDataFile = () => { if (regionIdCol && !newColumns?.includes(regionIdCol)) { store.regionSettings.regionIdColumn = null; } -} +}; diff --git a/src/types.ts b/src/types.ts index d189788..8f55ac8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,12 +25,12 @@ export enum CountryIdType { } export interface SingleCountrySettings { - countryISO: string | null + countryISO: string | null; } export interface MultiCountrySettings { - countryIdType: CountryIdType, - countryIdColumn: string | null + countryIdType: CountryIdType; + countryIdColumn: string | null; } export type CountrySettings = SingleCountrySettings | MultiCountrySettings; @@ -46,7 +46,7 @@ export enum RegionIdType { } export interface RegionSettings { - adminLevel: RegionAdminLevel, - regionIdType: RegionIdType, - regionIdColumn: string | null -} \ No newline at end of file + adminLevel: RegionAdminLevel; + regionIdType: RegionIdType; + regionIdColumn: string | null; +} diff --git a/tests/unit/components/ConditionalAccordionItem.svelte.test.ts b/tests/unit/components/ConditionalAccordionItem.svelte.test.ts index 4be500e..ecd22a2 100644 --- a/tests/unit/components/ConditionalAccordionItem.svelte.test.ts +++ b/tests/unit/components/ConditionalAccordionItem.svelte.test.ts @@ -1,43 +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'; +import ConditionalAccordionItem from "../../../src/components/ConditionalAccordionItem.svelte"; describe("Conditional Accordion Item", () => { - const children = createRawSnippet(() => { - return { - render: () => "
CAI content
", - setup: () => {} - }; - }); - const header = "Test Header" - test("renders as expected when open", () => { - render(ConditionalAccordionItem, { - props: { open: true, disabled: false, header, children } - } - ); - expect(screen.getByRole("button")).toBeVisible(); // expand buton exists - expect(screen.getByText(header)).toBeVisible(); - expect(screen.getByText(/CAI content/)).toBeVisible(); - }); + 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, header, children } - } - ); - expect(screen.getByRole("button")).toBeVisible(); // expand buton exists - expect(screen.getByText(header)).toBeVisible(); - expect(screen.queryByText(/CAI content/)).toBeNull(); - }); + 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, header, children } - } - ); - expect(screen.queryByRole("button")).toBeNull(); // expand buton does not exist - expect(screen.getByText(header)).toBeVisible(); - expect(screen.queryByText(/CAI content/)).toBeNull(); - }); -}); \ No newline at end of file + 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 055257c..f970266 100644 --- a/tests/unit/components/ProjectSetup/LoadFile.svelte.test.ts +++ b/tests/unit/components/ProjectSetup/LoadFile.svelte.test.ts @@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; import { getTestFile } from "../../utils"; import type { DataFile } from "$lib/DataFile"; +/* eslint-disable-next-line prefer-const */ let { mockStore, mockUpdateSettingsForNewDataFile } = vi.hoisted(() => ({ mockStore: {}, mockUpdateSettingsForNewDataFile: vi.fn() diff --git a/tests/unit/components/ProjectSetup/ProjectSetupDialog.svelte.test.ts b/tests/unit/components/ProjectSetup/ProjectSetupDialog.svelte.test.ts index 8a01e6a..f3fad41 100644 --- a/tests/unit/components/ProjectSetup/ProjectSetupDialog.svelte.test.ts +++ b/tests/unit/components/ProjectSetup/ProjectSetupDialog.svelte.test.ts @@ -1,77 +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'; +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, + finished: Promise.resolve(), + cancel: vi.fn(), + startTime: null, + currentTime: null }); HTMLDialogElement.prototype.show = vi.fn().mockImplementation(function (this: HTMLDialogElement) { - this.open = true; + this.open = true; }); HTMLDialogElement.prototype.close = vi.fn().mockImplementation(function (this: HTMLDialogElement) { - this.open = false; + this.open = false; }); const mockDataFile = { - columns: ["mockCol"] + 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 - } - } - }; - }); - }); + 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/}); - } + 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 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("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(); - }); - }); -}); \ No newline at end of file + 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 index 90967d3..4e80a7f 100644 --- a/tests/unit/components/ProjectSetup/SetAreas.svelte.test.ts +++ b/tests/unit/components/ProjectSetup/SetAreas.svelte.test.ts @@ -1,145 +1,152 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import {within} from '@testing-library/dom' +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"; +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 store = $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 - }; - }); - }); + 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 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); - }); - } + 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(); + 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(); - }); + 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 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 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 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 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 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); - }); -}); \ No newline at end of file + 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 62e4383..757ad13 100644 --- a/tests/unit/hooks.client.test.ts +++ b/tests/unit/hooks.client.test.ts @@ -2,7 +2,7 @@ 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'; +import { groutSuccessResponse } from "./utils"; const mockConfig = { appTitle: "Test Title", @@ -11,7 +11,7 @@ const mockConfig = { }; const mockCountries = [{ id: "AFG", name: "Afghanistan" }]; -const configHandler = http.get("./easymap.config.json", () => { +const configHandler = http.get("./easymap.config.json", () => { return HttpResponse.json(mockConfig); }); @@ -34,7 +34,7 @@ vi.mock("../../src/store.svelte.ts", () => { }; Object.defineProperty(mockStore.errors, "fetch", { set: mockSetError }); Object.defineProperty(mockStore, "appConfig", { set: mockSetConfig, get: () => mockConfig }); - Object.defineProperty(mockStore, "countries", {set: mockSetCountries}); + Object.defineProperty(mockStore, "countries", { set: mockSetCountries }); return { store: mockStore }; }); @@ -71,14 +71,16 @@ describe("Client hooks", () => { 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 }); - }) + 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(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 index 975bf4e..5661e1e 100644 --- a/tests/unit/store.svelte.test.ts +++ b/tests/unit/store.svelte.test.ts @@ -1,32 +1,32 @@ -import { beforeEach, describe, test, expect } from 'vitest'; -import { store, updateSettingsForNewDataFile } from '../../src/store.svelte'; -import { CountryIdType, CountrySettingsType, MultiCountrySettings } from '../../src/types'; +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"; - }); + 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("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(); - }); -}); \ No newline at end of file + 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 2f1a285..bb2be1e 100644 --- a/tests/unit/utils.ts +++ b/tests/unit/utils.ts @@ -24,10 +24,10 @@ export const getTestFile = (fileName: string): File => { return file; }; -export const groutSuccessResponse = (data: any) => { +export const groutSuccessResponse = (data: Array | object | string) => { return { status: "success", errors: null, data }; -}; \ No newline at end of file +};