diff --git a/README.md b/README.md
index 6ab2e34da..0d155c731 100644
--- a/README.md
+++ b/README.md
@@ -159,3 +159,8 @@ npx playwright test
```
from `app/client-v2/`.
To close all components once ready, run `./scripts/stop_test` from root.
+
+### Adding new species
+
+1. Add new database to [mrcdata](https://mrcdata.dide.ic.ac.uk/beebop).
+2. Add new species to `args.json` in *beebop_py*
diff --git a/app/client-v2/e2e/home.spec.ts b/app/client-v2/e2e/home.spec.ts
index 64db51aee..a0505462c 100644
--- a/app/client-v2/e2e/home.spec.ts
+++ b/app/client-v2/e2e/home.spec.ts
@@ -1,17 +1,12 @@
import { test, expect, Page } from "@playwright/test";
-import { randomProjectName } from "./utils.js";
+import { createProject, randomProjectName } from "./utils.js";
test.beforeEach(async ({ page }) => {
await page.goto("");
});
const addProjectNavigateHome = async (page: Page, name: string) => {
- await page.getByLabel("Create project").click();
- await page.getByLabel("Create", { exact: true }).click();
- await page.getByLabel("Select a Species").click();
- await page.getByLabel("Streptococcus pneumoniae").click();
- await page.getByLabel("Name").fill(name);
- await page.getByLabel("Create", { exact: true }).click();
+ await createProject(page, name)
await expect(page.getByText(name)).toBeVisible();
diff --git a/app/client-v2/e2e/projectPostRun.spec.ts b/app/client-v2/e2e/projectPostRun.spec.ts
index ce4850c30..374c0dc3f 100644
--- a/app/client-v2/e2e/projectPostRun.spec.ts
+++ b/app/client-v2/e2e/projectPostRun.spec.ts
@@ -1,12 +1,11 @@
import { expect, test } from "@playwright/test";
-import { randomProjectName, uploadFiles } from "./utils.js";
+import { createProject, randomProjectName, uploadFiles } from "./utils.js";
let projectName: string;
test.beforeEach(async ({ page }) => {
await page.goto("");
projectName = randomProjectName();
- await page.getByPlaceholder("Create new Project").fill(projectName);
- await page.getByPlaceholder("Create new Project").press("Enter");
+ await createProject(page, projectName);
});
test("can run project and view results", async ({ page }) => {
@@ -89,12 +88,3 @@ test("can run project multiple times", async ({ page }) => {
await expect(page.getByText("GPSC7")).toBeVisible();
await expect(page.getByText("GPSC4")).toBeVisible();
});
-
-test("can export project data as csv", async ({ page }) => {
- uploadFiles(page);
-
- const downloadPromise = page.waitForEvent("download");
- await page.getByLabel("Export").click();
- const download = await downloadPromise;
- expect(download.suggestedFilename()).toBe(`${projectName}.csv`);
-});
diff --git a/app/client-v2/e2e/projectPreRun.spec.ts b/app/client-v2/e2e/projectPreRun.spec.ts
index 833f9c07d..4d3bc076c 100644
--- a/app/client-v2/e2e/projectPreRun.spec.ts
+++ b/app/client-v2/e2e/projectPreRun.spec.ts
@@ -1,10 +1,11 @@
import { expect, test } from "@playwright/test";
-import { randomProjectName, uploadFiles } from "./utils.js";
+import { createProject, randomProjectName, uploadFiles } from "./utils.js";
+let projectName: string;
test.beforeEach(async ({ page }) => {
await page.goto("");
- await page.getByPlaceholder("Create new Project").fill(randomProjectName());
- await page.getByPlaceholder("Create new Project").press("Enter");
+ projectName = randomProjectName();
+ await createProject(page, projectName);
});
test("upload multiple files and display amr information", async ({ page }) => {
@@ -36,3 +37,12 @@ test("shows progress bar whilst uploading files & gone after full uploaded", asy
await expect(page.getByRole("progressbar")).not.toBeVisible();
});
+
+test("can export project data as csv", async ({ page }) => {
+ uploadFiles(page);
+
+ const downloadPromise = page.waitForEvent("download");
+ await page.getByLabel("Export").click();
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toBe(`${projectName}.csv`);
+});
diff --git a/app/client-v2/e2e/utils.ts b/app/client-v2/e2e/utils.ts
index 57b52abe5..dcceb77be 100644
--- a/app/client-v2/e2e/utils.ts
+++ b/app/client-v2/e2e/utils.ts
@@ -8,3 +8,12 @@ export const uploadFiles = async (page: Page, files = ["e2e/fastaFiles/good_1.fa
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(files);
};
+
+export const createProject = async (page: Page, name: string, species = "Streptococcus pneumoniae") => {
+ await page.getByLabel("Create project").click();
+ await page.getByLabel("Create", { exact: true }).click();
+ await page.getByLabel("Select a Species").click();
+ await page.getByLabel(species).click();
+ await page.getByLabel("Name").fill(name);
+ await page.getByLabel("Create", { exact: true }).click();
+};
diff --git a/app/client-v2/public/worker.js b/app/client-v2/public/worker.js
index a0c69f100..49868ce31 100644
--- a/app/client-v2/public/worker.js
+++ b/app/client-v2/public/worker.js
@@ -13,7 +13,10 @@ function createFS(module, f) {
module.FS.mount(module.FS.filesystems.WORKERFS, { files: [f] }, workdir);
}
-async function computeSample(hash, file, filename) {
+async function computeSample(fileObject, sketchKmerArguments) {
+ const { file, hash, filename } = fileObject;
+ const { kmerMin, kmerMax, kmerStep } = sketchKmerArguments;
+
const amrModule = await AMRprediction();
const sketchModule = await WebSketch();
//create working directory and mount file
@@ -21,18 +24,19 @@ async function computeSample(hash, file, filename) {
createFS(sketchModule, file);
const amr = amrModule.make_prediction_json(workdir + "/" + filename);
- // sketch() takes the followings arguments: filepath, kmer_min, kmer_max, kmer_step,
- // bbits, sketchsize64, codon_phased (boolean), use_rc (boolean)
- const sketch = sketchModule.sketch(workdir + "/" + filename, 14, 29, 3, 14, 156, false, true);
+ // sketch() takes the followings arguments:
+ // filepath, kmer_min, kmer_max, kmer_step, bbits, sketchsize64, codon_phased (boolean), use_rc (boolean)
+ const sketch = sketchModule.sketch(workdir + "/" + filename, kmerMin, kmerMax, kmerStep, 14, 156, false, true);
return { hash, amr, sketch };
}
onmessage = async function (message) {
- const hashedFiles = message.data;
+ const { hashedFiles, sketchKmerArguments } = message.data;
+
const samples = [];
for (const fileObject of hashedFiles) {
- const { hash, amr, sketch } = await computeSample(fileObject.hash, fileObject.file, fileObject.filename);
+ const { hash, amr, sketch } = await computeSample(fileObject, sketchKmerArguments);
samples.push({ hash, amr: JSON.parse(amr), sketch: JSON.parse(sketch), filename: fileObject.filename });
}
postMessage(samples);
diff --git a/app/client-v2/src/App.vue b/app/client-v2/src/App.vue
index 8bc384918..b247f7a8b 100644
--- a/app/client-v2/src/App.vue
+++ b/app/client-v2/src/App.vue
@@ -1,9 +1,12 @@
diff --git a/app/client-v2/src/__tests__/App.spec.ts b/app/client-v2/src/__tests__/App.spec.ts
index 4a73fcd72..97c1e9867 100644
--- a/app/client-v2/src/__tests__/App.spec.ts
+++ b/app/client-v2/src/__tests__/App.spec.ts
@@ -1,7 +1,10 @@
import AppVue from "@/App.vue";
+import { useSpeciesStore } from "@/stores/speciesStore";
+import { createTestingPinia } from "@pinia/testing";
import { render, screen } from "@testing-library/vue";
import { defineComponent } from "vue";
import { createRouter, createWebHistory } from "vue-router";
+import PrimeVue from "primevue/config";
const mockedThemeValues = {
setInitialTheme: vitest.fn(),
@@ -11,25 +14,31 @@ const mockedThemeValues = {
vitest.mock("@/composables/useTheme", () => ({
useTheme: () => mockedThemeValues
}));
+vitest.mock("primevue/usetoast", () => ({
+ useToast: vitest.fn()
+}));
+
const router = createRouter({
history: createWebHistory(),
routes: [{ path: "/about", component: defineComponent({ template: `About Page
` }) }]
});
describe("App", () => {
- it("should render about page(route) and call setInitialTheme on load of about page", async () => {
+ it("should render about page(route) and call setInitialTheme & setSpeciesConfig on load of about page", async () => {
router.push("/about");
await router.isReady();
render(AppVue, {
global: {
- plugins: [router],
+ plugins: [router, createTestingPinia()],
stubs: {
AppNav: true
}
}
});
+ const speciesStore = useSpeciesStore();
expect(screen.getByText(/about page/i)).toBeVisible();
expect(mockedThemeValues.setInitialTheme).toHaveBeenCalled();
+ expect(speciesStore.setSpeciesConfig).toHaveBeenCalled();
});
});
diff --git a/app/client-v2/src/__tests__/components/HomeView/CreateProjectButton.spec.ts b/app/client-v2/src/__tests__/components/HomeView/CreateProjectButton.spec.ts
index 65a55caf0..fcd81dcd9 100644
--- a/app/client-v2/src/__tests__/components/HomeView/CreateProjectButton.spec.ts
+++ b/app/client-v2/src/__tests__/components/HomeView/CreateProjectButton.spec.ts
@@ -4,11 +4,12 @@ import { createRouter, createWebHistory } from "vue-router";
import { defineComponent } from "vue";
import PrimeVue from "primevue/config";
import ToastService from "primevue/toastservice";
-import { MOCK_PROJECTS } from "@/mocks/mockObjects";
+import { MOCK_PROJECTS, MOCK_SPECIES } from "@/mocks/mockObjects";
import userEvent from "@testing-library/user-event";
import { server } from "@/mocks/server";
import { http, HttpResponse } from "msw";
import { projectIndexUri } from "@/mocks/handlers/projectHandlers";
+import { createTestingPinia } from "@pinia/testing";
const router = createRouter({
history: createWebHistory(),
@@ -20,7 +21,18 @@ const router = createRouter({
const renderComponent = () => {
render(CreateProjectButton, {
global: {
- plugins: [router, PrimeVue, ToastService]
+ plugins: [
+ router,
+ PrimeVue,
+ ToastService,
+ createTestingPinia({
+ initialState: {
+ species: {
+ species: MOCK_SPECIES
+ }
+ }
+ })
+ ]
},
props: {
projects: MOCK_PROJECTS
diff --git a/app/client-v2/src/__tests__/stores/projectStore.spec.ts b/app/client-v2/src/__tests__/stores/projectStore.spec.ts
index fb78748a4..e305a4b39 100644
--- a/app/client-v2/src/__tests__/stores/projectStore.spec.ts
+++ b/app/client-v2/src/__tests__/stores/projectStore.spec.ts
@@ -1,14 +1,21 @@
import { getApiUrl } from "@/config";
import { assignResultUri, projectIndexUri, statusUri } from "@/mocks/handlers/projectHandlers";
-import { MOCK_PROJECT, MOCK_PROJECT_SAMPLES, MOCK_PROJECT_SAMPLES_BEFORE_RUN } from "@/mocks/mockObjects";
+import {
+ MOCK_PROJECT,
+ MOCK_PROJECT_SAMPLES,
+ MOCK_PROJECT_SAMPLES_BEFORE_RUN,
+ MOCK_SPECIES,
+ MOCK_SPECIES_CONFIG
+} from "@/mocks/mockObjects";
import { server } from "@/mocks/server";
import { useProjectStore } from "@/stores/projectStore";
+import { useSpeciesStore } from "@/stores/speciesStore";
import {
- type ProjectSample,
AnalysisType,
type AnalysisStatus,
- type WorkerResponse,
- type HashedFile
+ type HashedFile,
+ type ProjectSample,
+ type WorkerResponse
} from "@/types/projectTypes";
import { flushPromises } from "@vue/test-utils";
import { HttpResponse, http } from "msw";
@@ -135,6 +142,16 @@ describe("projectStore", () => {
expect(processFilesSpy).toHaveBeenCalledWith([newFileWithHash]);
});
+ it("should show error toast and not call processFiles when onFilesUpload is called with 0 new files", async () => {
+ const store = useProjectStore();
+ store.toast.showErrorToast = vitest.fn();
+ store.project.samples = mockFilesWithHashes.map((file) => ({ hash: file.hash, filename: file.name }));
+ const processFilesSpy = vitest.spyOn(store, "processFiles");
+ store.onFilesUpload(mockFilesWithHashes[0]);
+
+ expect(store.toast.showErrorToast).toHaveBeenCalledWith("No new files to upload.");
+ expect(processFilesSpy).not.toHaveBeenCalled();
+ });
it("should call batchFilesForProcessing and processFileBatches when processFiles is called", async () => {
const mockHashedFileBatches = [[{ hash: "test-hash" }], [{ hash: "test-hash" }]] as HashedFile[][];
const store = useProjectStore();
@@ -511,22 +528,27 @@ describe("projectStore", () => {
expect(fileBatches.length).toBe(Math.ceil(mockFilesWithHashes.length / 5));
});
- it("should process file batches correctly when processFileBatches is called", async () => {
+ it("should process file batches correctly with species kmer args when processFileBatches is called", async () => {
const mockFilesWithHashes = Array.from({ length: 98 }, (_, index) => ({
name: `sample${index + 1}.fasta`,
text: () => Promise.resolve(`sample${index + 1}`)
})) as unknown as File[];
- const store = useProjectStore();
- store.getOptimalWorkerCount = vitest.fn().mockReturnValue(8);
- const batchPromise = vitest.spyOn(store, "computeAmrAndSketch").mockResolvedValue();
- const hashedFileBatches = await store.batchFilesForProcessing(mockFilesWithHashes);
+ const speciesStore = useSpeciesStore();
+ speciesStore.sketchKmerArguments = MOCK_SPECIES_CONFIG;
+
+ const projectStore = useProjectStore();
+ projectStore.project.species = MOCK_SPECIES[0];
+
+ projectStore.getOptimalWorkerCount = vitest.fn().mockReturnValue(8);
+ const batchPromise = vitest.spyOn(projectStore, "computeAmrAndSketch").mockResolvedValue();
+ const hashedFileBatches = await projectStore.batchFilesForProcessing(mockFilesWithHashes);
- await store.processFileBatches(hashedFileBatches);
+ await projectStore.processFileBatches(hashedFileBatches);
await flushPromises();
- expect(store.uploadingPercentage).toEqual(100);
+ expect(projectStore.uploadingPercentage).toEqual(100);
hashedFileBatches.forEach((batch) => {
- expect(batchPromise).toHaveBeenCalledWith(batch);
+ expect(batchPromise).toHaveBeenCalledWith(batch, MOCK_SPECIES_CONFIG[MOCK_SPECIES[0]]);
});
});
});
diff --git a/app/client-v2/src/__tests__/stores/speciesStore.spec.ts b/app/client-v2/src/__tests__/stores/speciesStore.spec.ts
new file mode 100644
index 000000000..5a191fd13
--- /dev/null
+++ b/app/client-v2/src/__tests__/stores/speciesStore.spec.ts
@@ -0,0 +1,69 @@
+import { speciesConfigIndexUri } from "@/mocks/handlers/configHandlers";
+import { MOCK_SPECIES_CONFIG } from "@/mocks/mockObjects";
+import { server } from "@/mocks/server";
+import { useSpeciesStore } from "@/stores/speciesStore";
+import { http, HttpResponse } from "msw";
+import { createPinia, setActivePinia } from "pinia";
+
+const mockToastAdd = vitest.fn();
+vitest.mock("primevue/usetoast", () => ({
+ useToast: vitest.fn(() => ({
+ add: mockToastAdd
+ }))
+}));
+
+describe("SpeciesStore", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ });
+
+ describe("getters", () => {
+ it("should return correct SketchArguments when getSketchKmerArguments is called", () => {
+ const store = useSpeciesStore();
+ store.sketchKmerArguments = MOCK_SPECIES_CONFIG;
+
+ const result = store.getSketchKmerArguments("Streptococcus pneumoniae");
+
+ expect(result).toEqual(MOCK_SPECIES_CONFIG["Streptococcus pneumoniae"]);
+ });
+
+ it("should return undefined when getSketchKmerArguments is called with an invalid species", () => {
+ const store = useSpeciesStore();
+ store.sketchKmerArguments = {
+ "Streptococcus pneumoniae": {
+ kmerMax: 14,
+ kmerMin: 3,
+ kmerStep: 3
+ }
+ } as any;
+
+ const result = store.getSketchKmerArguments("Streptococcus agalactiae");
+
+ expect(result).toBeUndefined();
+ });
+ });
+ describe("actions", () => {
+ it("should set speciesConfig from api when setSpeciesConfig is called", async () => {
+ const store = useSpeciesStore();
+
+ await store.setSpeciesConfig();
+
+ expect(store.sketchKmerArguments).toEqual(MOCK_SPECIES_CONFIG);
+ expect(store.species).toEqual(Object.keys(MOCK_SPECIES_CONFIG));
+ });
+
+ it("should show error toast when setSpeciesConfig fails", async () => {
+ server.use(http.get(speciesConfigIndexUri, () => HttpResponse.error()));
+ const store = useSpeciesStore();
+
+ await store.setSpeciesConfig();
+
+ expect(mockToastAdd).toHaveBeenCalledWith({
+ severity: "error",
+ detail: "Failed to fetch sketch kmer arguments, please try again later",
+ life: 5000,
+ summary: "Error"
+ });
+ });
+ });
+});
diff --git a/app/client-v2/src/__tests__/views/HomeView.spec.ts b/app/client-v2/src/__tests__/views/HomeView.spec.ts
index 4d5ebd703..e6363beb6 100644
--- a/app/client-v2/src/__tests__/views/HomeView.spec.ts
+++ b/app/client-v2/src/__tests__/views/HomeView.spec.ts
@@ -1,7 +1,6 @@
import { projectIndexUri } from "@/mocks/handlers/projectHandlers";
-import { MOCK_PROJECTS } from "@/mocks/mockObjects";
+import { MOCK_PROJECTS, MOCK_SPECIES_CONFIG } from "@/mocks/mockObjects";
import { server } from "@/mocks/server";
-import { SPECIES } from "@/types/projectTypes";
import HomeViewVue from "@/views/HomeView.vue";
import userEvent from "@testing-library/user-event";
import { render, screen, waitFor, within } from "@testing-library/vue";
@@ -40,6 +39,7 @@ const renderComponent = () => {
describe("HomeView ", () => {
it("should render projects with links to each project on initial render", async () => {
+ const mockSpecies = Object.keys(MOCK_SPECIES_CONFIG);
renderComponent();
await waitFor(() => {
@@ -50,8 +50,8 @@ describe("HomeView ", () => {
expect(screen.getByRole("button", { name: new RegExp(`delete ${project.name}`, "i") })).toBeVisible();
});
});
- expect(screen.getAllByText(SPECIES[0]).length).toBe(2);
- expect(screen.getAllByText(SPECIES[1]).length).toBe(1);
+ expect(screen.getAllByText(mockSpecies[0]).length).toBe(2);
+ expect(screen.getAllByText(mockSpecies[1]).length).toBe(1);
});
it("should render create project button", async () => {
renderComponent();
diff --git a/app/client-v2/src/__tests__/worker.spec.ts b/app/client-v2/src/__tests__/worker.spec.ts
index 9da64b005..3a93e6590 100644
--- a/app/client-v2/src/__tests__/worker.spec.ts
+++ b/app/client-v2/src/__tests__/worker.spec.ts
@@ -23,10 +23,19 @@ script.runInNewContext(worker);
describe("Worker", () => {
const message = {
- data: [
- { hash: "abc123", file: { name: "mockfile1.fa" }, filename: "mockfile1.fa" },
- { hash: "abc2234", file: { name: "mockfile2.fa" }, filename: "mockfile2.fa" }
- ]
+ data: {
+ hashedFiles: [
+ { hash: "abc123", file: { name: "mockfile1.fa" }, filename: "mockfile1.fa" },
+ { hash: "abc2234", file: { name: "mockfile2.fa" }, filename: "mockfile2.fa" }
+ ],
+ sketchKmerArguments: {
+ "Streptococcus pneumoniae": {
+ kmerMax: 14,
+ kmerMin: 3,
+ kmerStep: 3
+ }
+ }
+ }
};
(worker.onmessage as any)(message);
@@ -36,7 +45,7 @@ describe("Worker", () => {
});
it("puts file into workdir of FS", async () => {
- expect(worker.moduleMock.data.filedata).toEqual({ files: [message.data[1].file] });
+ expect(worker.moduleMock.data.filedata).toEqual({ files: [message.data.hashedFiles[1].file] });
return expect(worker.moduleMock.data.dir).toBe(worker.moduleMock.workdir);
});
diff --git a/app/client-v2/src/components/HomeView/CreateProjectButton.vue b/app/client-v2/src/components/HomeView/CreateProjectButton.vue
index 800558e68..d9471f5ca 100644
--- a/app/client-v2/src/components/HomeView/CreateProjectButton.vue
+++ b/app/client-v2/src/components/HomeView/CreateProjectButton.vue
@@ -1,7 +1,8 @@