diff --git a/app/static/src/app/components/fit/FitTab.vue b/app/static/src/app/components/fit/FitTab.vue index e0577c0f7..289763544 100644 --- a/app/static/src/app/components/fit/FitTab.vue +++ b/app/static/src/app/components/fit/FitTab.vue @@ -21,6 +21,7 @@
{{cancelledMsg}}
+ @@ -39,10 +40,12 @@ import { ModelFitMutation } from "../../store/modelFit/mutations"; import { fitRequirementsExplanation, fitUpdateRequiredExplanation } from "./support"; import { allTrue, anyTrue } from "../../utils"; import LoadingButton from "../LoadingButton.vue"; +import ErrorInfo from "../ErrorInfo.vue"; export default defineComponent({ name: "FitTab", components: { + ErrorInfo, LoadingSpinner, FitPlot, ActionRequiredMessage, @@ -57,6 +60,7 @@ export default defineComponent({ const canFitModel = computed(() => allTrue(fitRequirements.value)); const compileRequired = computed(() => store.state.model.compileRequired); const fitUpdateRequired = computed(() => store.state.modelFit.fitUpdateRequired); + const error = computed(() => store.state.modelFit.error); const fitModel = () => store.dispatch(`${namespace}/${ModelFitAction.FitModel}`); const cancelFit = () => store.commit(`${namespace}/${ModelFitMutation.SetFitting}`, false); @@ -68,6 +72,10 @@ export default defineComponent({ const sumOfSquares = computed(() => store.state.modelFit.sumOfSquares); const actionRequiredMessage = computed(() => { + if (error.value) { + return userMessages.modelFit.errorOccurred; + } + if (!allTrue(fitRequirements.value)) { return fitRequirementsExplanation(fitRequirements.value); } @@ -119,6 +127,7 @@ export default defineComponent({ cancelledMsg, sumOfSquares, actionRequiredMessage, + error, iconType, iconClass }; diff --git a/app/static/src/app/serialise.ts b/app/static/src/app/serialise.ts index 7585566c4..f768e98a4 100644 --- a/app/static/src/app/serialise.ts +++ b/app/static/src/app/serialise.ts @@ -130,7 +130,8 @@ function serialiseModelFit(modelFit: ModelFitState): SerialisedModelFitState { converged: modelFit.converged, sumOfSquares: modelFit.sumOfSquares, paramsToVary: modelFit.paramsToVary, - result: serialiseSolutionResult(modelFit.result) + result: serialiseSolutionResult(modelFit.result), + error: modelFit.error }; } diff --git a/app/static/src/app/store/appState/mutations.ts b/app/static/src/app/store/appState/mutations.ts index 67128a618..93de9390e 100644 --- a/app/static/src/app/store/appState/mutations.ts +++ b/app/static/src/app/store/appState/mutations.ts @@ -21,7 +21,8 @@ export enum AppStateMutation { export const StateUploadMutations = [ AppStateMutation.ClearQueuedStateUpload, AppStateMutation.SetQueuedStateUpload, - AppStateMutation.SetStateUploadInProgress + AppStateMutation.SetStateUploadInProgress, + AppStateMutation.SetPersisted ] as string[]; export const appStateMutations: MutationTree = { diff --git a/app/static/src/app/store/modelFit/actions.ts b/app/static/src/app/store/modelFit/actions.ts index a7c8ca8e2..16822b78a 100644 --- a/app/static/src/app/store/modelFit/actions.ts +++ b/app/static/src/app/store/modelFit/actions.ts @@ -50,24 +50,28 @@ export const actions: ActionTree = { const { advancedSettings } = rootState.run; const advancedSettingsOdin = convertAdvancedSettingsToOdin(advancedSettings, pars.base); - const simplex = odinRunnerOde!.wodinFit( - odin!, - data, - pars, - linkedVariable, - advancedSettingsOdin, - {} - ); - - const inputs = { - data, - endTime, - link - }; - - commit(ModelFitMutation.SetFitUpdateRequired, null); - commit(ModelFitMutation.SetInputs, inputs); - dispatch(ModelFitAction.FitModelStep, simplex); + try { + const simplex = odinRunnerOde!.wodinFit( + odin!, + data, + pars, + linkedVariable, + advancedSettingsOdin, + {} + ); + + const inputs = { + data, + endTime, + link + }; + commit(ModelFitMutation.SetFitUpdateRequired, null); + commit(ModelFitMutation.SetInputs, inputs); + dispatch(ModelFitAction.FitModelStep, simplex); + } catch (e: unknown) { + commit(ModelFitMutation.SetError, { error: "Model fit error", detail: (e as Error).message }); + commit(ModelFitMutation.SetFitting, false); + } } }, diff --git a/app/static/src/app/store/modelFit/modelFit.ts b/app/static/src/app/store/modelFit/modelFit.ts index a5bca25b3..0048dc8c6 100644 --- a/app/static/src/app/store/modelFit/modelFit.ts +++ b/app/static/src/app/store/modelFit/modelFit.ts @@ -18,7 +18,8 @@ export const defaultState: ModelFitState = { sumOfSquares: null, paramsToVary: [], inputs: null, - result: null + result: null, + error: null }; export const modelFit = { diff --git a/app/static/src/app/store/modelFit/mutations.ts b/app/static/src/app/store/modelFit/mutations.ts index 976ec1e88..cde61b45c 100644 --- a/app/static/src/app/store/modelFit/mutations.ts +++ b/app/static/src/app/store/modelFit/mutations.ts @@ -1,6 +1,6 @@ import { MutationTree } from "vuex"; import { ModelFitInputs, ModelFitState, FitUpdateRequiredReasons } from "./state"; -import { SimplexResult } from "../../types/responseTypes"; +import { SimplexResult, WodinError } from "../../types/responseTypes"; export enum ModelFitMutation { SetFitting = "SetFitting", @@ -8,12 +8,16 @@ export enum ModelFitMutation { SetInputs = "SetInputs", SetParamsToVary = "SetParamsToVary", SetSumOfSquares = "SetSumOfSquares", - SetFitUpdateRequired = "SetFitUpdateRequired" + SetFitUpdateRequired = "SetFitUpdateRequired", + SetError = "SetError" } export const mutations: MutationTree = { [ModelFitMutation.SetFitting](state: ModelFitState, payload: boolean) { state.fitting = payload; + if (payload) { + state.error = null; + } }, [ModelFitMutation.SetResult](state: ModelFitState, payload: SimplexResult) { @@ -43,6 +47,10 @@ export const mutations: MutationTree = { state.sumOfSquares = payload; }, + [ModelFitMutation.SetError](state: ModelFitState, payload: WodinError) { + state.error = payload; + }, + [ModelFitMutation.SetFitUpdateRequired](state: ModelFitState, payload: null | Partial) { if (payload === null) { state.fitUpdateRequired = { diff --git a/app/static/src/app/store/modelFit/state.ts b/app/static/src/app/store/modelFit/state.ts index e2fe35acf..cb264a2f6 100644 --- a/app/static/src/app/store/modelFit/state.ts +++ b/app/static/src/app/store/modelFit/state.ts @@ -1,5 +1,6 @@ import { OdinFitResult } from "../../types/wrapperTypes"; import type { FitData, FitDataLink } from "../fitData/state"; +import { WodinError } from "../../types/responseTypes"; export interface ModelFitInputs { data: FitData; @@ -25,6 +26,7 @@ export interface ModelFitState { paramsToVary: string[], inputs: ModelFitInputs | null, // all inputs except parameters, which vary result: OdinFitResult | null, // full solution for current best fit, + error: null | WodinError } export interface ModelFitRequirements { diff --git a/app/static/src/app/types/serialisationTypes.ts b/app/static/src/app/types/serialisationTypes.ts index 19fc54d5e..8f242a946 100644 --- a/app/static/src/app/types/serialisationTypes.ts +++ b/app/static/src/app/types/serialisationTypes.ts @@ -77,7 +77,8 @@ export interface SerialisedModelFitState { converged: boolean | null, sumOfSquares: number | null, paramsToVary: string[], - result: SerialisedRunResult | null + result: SerialisedRunResult | null, + error: null | WodinError } export interface SerialisedAppState { diff --git a/app/static/src/app/userMessages.ts b/app/static/src/app/userMessages.ts index d6c9968c9..f4c43cf1e 100644 --- a/app/static/src/app/userMessages.ts +++ b/app/static/src/app/userMessages.ts @@ -56,6 +56,7 @@ export default { notFittedYet: "Model has not been fitted.", selectParamToVary: "Please select at least one parameter to vary during model fit.", compileRequired: "Model code has been updated. Compile code and Fit Model for updated best fit.", + errorOccurred: "An error occurred during model fit.", updateFitReasons: { prefix: "Fit is out of date:", unknown: "unknown reasons, contact the administrator, as this is unexpected", diff --git a/app/static/tests/e2e/fit.etest.ts b/app/static/tests/e2e/fit.etest.ts index cf38deb17..c6618064f 100644 --- a/app/static/tests/e2e/fit.etest.ts +++ b/app/static/tests/e2e/fit.etest.ts @@ -163,6 +163,41 @@ test.describe("Wodin App model fit tests", () => { expect(newSumOfSquares).not.toEqual(sumOfSquares); }); + test("can show model fit error", async ({ page }) => { + // Upload data + await uploadCSVData(page, realisticFitData); + await page.click(":nth-match(.wodin-right .nav-tabs a, 2)"); + + // link variables + await page.click(":nth-match(.wodin-left .nav-tabs a, 3)"); + const linkContainer = await page.locator(":nth-match(.collapse .container, 1)"); + const select1 = await linkContainer.locator(":nth-match(select, 1)"); + await select1.selectOption("E"); + + await expect(await page.innerText("#optimisation label#target-fit-label")).toBe("Cases ~ E"); + + // select param to vary + await page.click(":nth-match(.wodin-right .nav-tabs a, 2)"); + await expect(await page.innerText("#select-param-msg")) + .toBe("Please select at least one parameter to vary during model fit."); + await page.click(":nth-match(input.vary-param-check, 1)"); + await page.click(":nth-match(input.vary-param-check, 2)"); + + // change advanced setting to sabotage fit + await page.click(":nth-match(.collapse-title, 6)"); // Open Advanced Settings + const advancedSettingPanel = await page.locator("#advanced-settings-panel"); + const input1 = await advancedSettingPanel.locator(":nth-match(input, 1)"); + const input2 = await advancedSettingPanel.locator(":nth-match(input, 2)"); + await input1.fill("7"); + await input2.fill("-1"); + + await page.click(".wodin-right .wodin-content div.mt-4 button#fit-btn"); + + await expect(await page.locator(".fit-tab .action-required-msg")) + .toHaveText("An error occurred during model fit.", { timeout }); + await expect(await page.innerText(".fit-tab #error-info")).toContain("Model fit error: Integration failure"); + }); + test("can see expected update required messages when code changes", async ({ page }) => { await runFit(page); await expectUpdateFitMsg(page, ""); diff --git a/app/static/tests/mocks.ts b/app/static/tests/mocks.ts index f62fe4b54..959c8bb32 100644 --- a/app/static/tests/mocks.ts +++ b/app/static/tests/mocks.ts @@ -239,6 +239,7 @@ export const mockModelFitState = (state: Partial = {}): ModelFitS paramsToVary: [], inputs: null, result: null, + error: null, ...state }; }; diff --git a/app/static/tests/unit/components/fit/fitTab.test.ts b/app/static/tests/unit/components/fit/fitTab.test.ts index b0c5d99bf..3408250fe 100644 --- a/app/static/tests/unit/components/fit/fitTab.test.ts +++ b/app/static/tests/unit/components/fit/fitTab.test.ts @@ -11,6 +11,8 @@ import ActionRequiredMessage from "../../../../src/app/components/ActionRequired import LoadingSpinner from "../../../../src/app/components/LoadingSpinner.vue"; import FitPlot from "../../../../src/app/components/fit/FitPlot.vue"; import { mockFitState, mockGraphSettingsState } from "../../../mocks"; +import { WodinError } from "../../../../src/app/types/responseTypes"; +import ErrorInfo from "../../../../src/app/components/ErrorInfo.vue"; describe("Fit Tab", () => { const getWrapper = ( @@ -22,7 +24,8 @@ describe("Fit Tab", () => { fitting = false, sumOfSquares: number | null = 2.1, mockFitModel = jest.fn(), - mockSetFitting = jest.fn() + mockSetFitting = jest.fn(), + error: WodinError | null = null ) => { const store = new Vuex.Store({ state: mockFitState(), @@ -45,6 +48,7 @@ describe("Fit Tab", () => { fitting, sumOfSquares, fitUpdateRequired, + error, result: { solution: jest.fn() } @@ -82,6 +86,7 @@ describe("Fit Tab", () => { expect(fitPlot.findAll("span").at(0)!.text()).toBe("Iterations: 10"); expect(fitPlot.findAll("span").at(1)!.text()).toBe("Sum of squares: 2.1"); expect(fitPlot.find("#fit-cancelled-msg").exists()).toBe(false); + expect(wrapper.findComponent(ErrorInfo).props("error")).toBe(null); }); it("renders as expected when fit is running", () => { @@ -169,6 +174,14 @@ describe("Fit Tab", () => { expect(fitPlot.findAll("span").at(1)!.text()).toBe("Sum of squares: 2.1"); }); + it("renders model fit error as expected", () => { + const error = { error: "test error", detail: "test detail" }; + const wrapper = getWrapper({}, false, {}, 10, true, false, 2.1, jest.fn(), jest.fn(), error); + expect(wrapper.findComponent(ErrorInfo).props("error")).toStrictEqual(error); + expect(wrapper.findComponent(ActionRequiredMessage).props("message")) + .toBe("An error occurred during model fit."); + }); + it("dispatches fit action on click button", async () => { const mockFitModel = jest.fn(); const wrapper = getWrapper({}, false, false, null, null, false, null, mockFitModel); diff --git a/app/static/tests/unit/serialiser.test.ts b/app/static/tests/unit/serialiser.test.ts index 318603c40..31778b3b8 100644 --- a/app/static/tests/unit/serialiser.test.ts +++ b/app/static/tests/unit/serialiser.test.ts @@ -292,6 +292,7 @@ describe("serialise", () => { const modelFitState = { fitting: false, + error: { error: "fit run error", detail: "fit run detail" }, fitUpdateRequired: { modelChanged: false, dataChanged: false, @@ -482,6 +483,7 @@ describe("serialise", () => { }; const expectedModelFit = { + error: { error: "fit run error", detail: "fit run detail" }, fitUpdateRequired: { modelChanged: false, dataChanged: false, diff --git a/app/static/tests/unit/store/modelFit/actions.test.ts b/app/static/tests/unit/store/modelFit/actions.test.ts index cfc1c02a2..72dffa13c 100644 --- a/app/static/tests/unit/store/modelFit/actions.test.ts +++ b/app/static/tests/unit/store/modelFit/actions.test.ts @@ -135,6 +135,39 @@ describe("ModelFit actions", () => { expect(mockWodinFit).not.toHaveBeenCalled(); }); + it("FitModel commits error thrown during wodinFit", () => { + const runner = { + wodinFit: jest.fn().mockImplementation(() => { throw new Error("TEST ERROR"); }), + wodinFitValue: mockWodinFitValue + } as any; + const errorModelState = mockModelState({ + odin: mockOdin, + odinRunnerOde: runner + }); + + const errorRootState = { ...rootState, model: errorModelState }; + + const getters = { fitRequirements: {} }; + const link = { time: "t", data: "v", model: "S" }; + const rootGetters = { + "fitData/link": link, + "fitData/dataEnd": 100 + }; + const commit = jest.fn(); + const dispatch = jest.fn(); + (actions[ModelFitAction.FitModel] as any)({ + commit, dispatch, state, rootState: errorRootState, rootGetters, getters + }); + + expect(commit).toHaveBeenCalledTimes(3); + expect(commit.mock.calls[0][0]).toBe(ModelFitMutation.SetFitting); + expect(commit.mock.calls[0][1]).toBe(true); + expect(commit.mock.calls[1][0]).toBe(ModelFitMutation.SetError); + expect(commit.mock.calls[1][1]).toStrictEqual({ error: "Model fit error", detail: "TEST ERROR" }); + expect(commit.mock.calls[2][0]).toBe(ModelFitMutation.SetFitting); + expect(commit.mock.calls[2][1]).toBe(false); + }); + it("FitModelStep commits expected changes and dispatches further step if not converged", (done) => { const result = { converged: false, diff --git a/app/static/tests/unit/store/modelFit/mutations.test.ts b/app/static/tests/unit/store/modelFit/mutations.test.ts index ea6e0b5f2..53fc89945 100644 --- a/app/static/tests/unit/store/modelFit/mutations.test.ts +++ b/app/static/tests/unit/store/modelFit/mutations.test.ts @@ -18,6 +18,22 @@ describe("ModelFit mutations", () => { expect(state.fitting).toBe(true); }); + it("SetFitting sets error to null if fitting is true", () => { + const error = { error: "test", detail: "test detail" }; + const state = mockModelFitState({ error }); + mutations.SetFitting(state, false); + expect(state.error).toBe(error); + mutations.SetFitting(state, true); + expect(state.error).toBe(null); + }); + + it("sets error", () => { + const error = { error: "test", detail: "test detail" }; + const state = mockModelFitState(); + mutations.SetError(state, error); + expect(state.error).toBe(error); + }); + it("sets model fit inputs", () => { const state = mockModelFitState(); mutations.SetInputs(state, mockInputs);