diff --git a/frontend/cypress/e2e/unit/modelParameterService.cy.ts b/frontend/cypress/e2e/unit/modelParameterService.cy.ts new file mode 100644 index 00000000..99351cce --- /dev/null +++ b/frontend/cypress/e2e/unit/modelParameterService.cy.ts @@ -0,0 +1,168 @@ +/// + +import { createCSVFiles, runModel } from '@/services/modelParameterService' +import { SelectedExecutionOptionsEnum } from '@/services/vdyp-api' +import * as apiActions from '@/services/apiActions' +import { CONSTANTS, DEFAULTS, OPTIONS } from '@/constants' +import sinon from 'sinon' + +describe('Model Parameter Service Unit Tests', () => { + const mockModelParameterStore = { + derivedBy: DEFAULTS.DEFAULT_VALUES.DERIVED_BY, + speciesList: [ + { species: 'PL', percent: '30.0' }, + { species: 'AC', percent: '30.0' }, + { species: 'H', percent: '30.0' }, + { species: 'S', percent: '10.0' }, + { species: null, percent: '0.0' }, + { species: null, percent: '0.0' }, + ], + becZone: DEFAULTS.DEFAULT_VALUES.BEC_ZONE, + ecoZone: OPTIONS.ecoZoneOptions[0].value, + incSecondaryHeight: false, + highestPercentSpecies: 'PL', + bha50SiteIndex: DEFAULTS.DEFAULT_VALUES.BHA50_SITE_INDEX, + percentStockableArea: DEFAULTS.DEFAULT_VALUES.PERCENT_STOCKABLE_AREA, + startingAge: DEFAULTS.DEFAULT_VALUES.STARTING_AGE, + finishingAge: DEFAULTS.DEFAULT_VALUES.FINISHING_AGE, + ageIncrement: DEFAULTS.DEFAULT_VALUES.AGE_INCREMENT, + } + + let projectionStub: sinon.SinonStub + + beforeEach(() => { + projectionStub = sinon + .stub(apiActions, 'projectionHcsvPost') + .resolves(new Blob(['mock response'], { type: 'application/json' })) + }) + + afterEach(() => { + projectionStub.restore() + }) + + it('should call projectionHcsvPost once', async () => { + await runModel(mockModelParameterStore) + expect(projectionStub.calledOnce).to.be.true + }) + + it('should create CSV files correctly', () => { + const { blobPolygon, blobLayer } = createCSVFiles(mockModelParameterStore) + + cy.wrap(blobPolygon).should('be.instanceOf', Blob) + cy.wrap(blobLayer).should('be.instanceOf', Blob) + + const derivedByCode = + mockModelParameterStore.derivedBy === CONSTANTS.DERIVED_BY.VOLUME + ? CONSTANTS.INVENTORY_CODES.FIP + : mockModelParameterStore.derivedBy === CONSTANTS.DERIVED_BY.BASAL_AREA + ? CONSTANTS.INVENTORY_CODES.VRI + : '' + + cy.wrap(blobPolygon.text()).then((text) => { + expect(text).to.include(derivedByCode) + expect(text).to.include(mockModelParameterStore.becZone) + expect(text).to.include(mockModelParameterStore.ecoZone) + expect(text).to.include(mockModelParameterStore.percentStockableArea) + }) + + cy.wrap(blobLayer.text()).then((text) => { + expect(text).to.include(mockModelParameterStore.highestPercentSpecies) + expect(text).to.include(mockModelParameterStore.bha50SiteIndex) + expect(text).to.include('PL,30.0') + expect(text).to.include('AC,30.0') + expect(text).to.include('H,30.0') + expect(text).to.include('S,10.0') + }) + }) + + it('should reject with error for invalid model parameters', async () => { + const invalidModelParameterStore = { + ...mockModelParameterStore, + startingAge: null, + } + + try { + await runModel(invalidModelParameterStore) + } catch (error) { + expect(error).to.be.an('error') + } + }) + + it('should call projectionHcsvPost with correct form data', async () => { + await runModel(mockModelParameterStore) + + expect(projectionStub.calledOnce).to.be.true + const formDataArg = projectionStub.getCall(0).args[0] as FormData + + expect(formDataArg.has('polygonInputData')).to.be.true + expect(formDataArg.has('layersInputData')).to.be.true + expect(formDataArg.has('projectionParameters')).to.be.true + }) + + it('should include additional options when secondary height is enabled', async () => { + const updatedModelParameterStore = { + ...mockModelParameterStore, + incSecondaryHeight: true, + } + + await runModel(updatedModelParameterStore) + + const formDataArg = projectionStub.getCall(0).args[0] as FormData + const projectionParamsBlob = formDataArg.get('projectionParameters') as Blob + + const projectionParamsText = await projectionParamsBlob.text() + const projectionParams = JSON.parse(projectionParamsText) + + expect(projectionParams.selectedExecutionOptions).to.include( + SelectedExecutionOptionsEnum.DoIncludeSecondarySpeciesDominantHeightInYieldTable, + ) + }) + + it('should contain expected execution options', async () => { + await runModel(mockModelParameterStore) + + const formDataArg = projectionStub.getCall(0).args[0] as FormData + const projectionParamsBlob = formDataArg.get('projectionParameters') as Blob + + const projectionParamsText = await projectionParamsBlob.text() + const projectionParams = JSON.parse(projectionParamsText) + + expect(projectionParams.selectedExecutionOptions).to.include( + SelectedExecutionOptionsEnum.DoEnableProgressLogging, + SelectedExecutionOptionsEnum.DoEnableErrorLogging, + ) + }) + + it('should handle empty species list without errors', async () => { + const emptySpeciesStore = { + ...mockModelParameterStore, + speciesList: new Array(6).fill({ species: null, percent: '0.0' }), + } + + await runModel(emptySpeciesStore) + expect(projectionStub.calledOnce).to.be.true + }) + + it('should handle missing fields in model parameter store', () => { + const invalidStore = { + ...mockModelParameterStore, + becZone: undefined, + } + + const { blobPolygon } = createCSVFiles(invalidStore) + cy.wrap(blobPolygon.text()).then((text) => { + expect(text).not.to.include(DEFAULTS.DEFAULT_VALUES.BEC_ZONE) + }) + }) + + it('should handle projectionHcsvPost failure', async () => { + projectionStub.rejects(new Error('API call failed')) + + try { + await runModel(mockModelParameterStore) + } catch (error) { + expect(error).to.be.an('error') + expect(error.message).to.equal('API call failed') + } + }) +}) diff --git a/frontend/cypress/e2e/unit/modelParameterStore.cy.ts b/frontend/cypress/e2e/unit/modelParameterStore.cy.ts new file mode 100644 index 00000000..97c2d7ce --- /dev/null +++ b/frontend/cypress/e2e/unit/modelParameterStore.cy.ts @@ -0,0 +1,120 @@ +/// + +import { setActivePinia, createPinia } from 'pinia' +import { useModelParameterStore } from '@/stores/modelParameterStore' +import { CONSTANTS, DEFAULTS } from '@/constants' + +describe('ModelParameterStore Unit Tests', () => { + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + store = useModelParameterStore() + }) + + it('should initialize with default values', () => { + expect(store.panelOpenStates.speciesInfo).to.equal(CONSTANTS.PANEL.OPEN) + expect(store.panelOpenStates.siteInfo).to.equal(CONSTANTS.PANEL.CLOSE) + expect(store.panelOpenStates.standDensity).to.equal(CONSTANTS.PANEL.CLOSE) + expect(store.panelOpenStates.reportInfo).to.equal(CONSTANTS.PANEL.CLOSE) + + expect(store.panelState.speciesInfo.confirmed).to.be.false + expect(store.panelState.speciesInfo.editable).to.be.true + + expect(store.panelState.siteInfo.confirmed).to.be.false + expect(store.panelState.siteInfo.editable).to.be.false + + expect(store.panelState.standDensity.confirmed).to.be.false + expect(store.panelState.standDensity.editable).to.be.false + + expect(store.panelState.reportInfo.confirmed).to.be.false + expect(store.panelState.reportInfo.editable).to.be.false + + expect(store.runModelEnabled).to.be.false + }) + + it('should confirm panel and enable the next panel', () => { + store.confirmPanel(CONSTANTS.MODEL_PARAMETER_PANEL.SPECIES_INFO) + + expect(store.panelState.speciesInfo.confirmed).to.be.true + expect(store.panelState.speciesInfo.editable).to.be.false + expect(store.panelOpenStates.siteInfo).to.equal(CONSTANTS.PANEL.OPEN) + expect(store.panelState.siteInfo.editable).to.be.true + }) + + it('should edit panel and disable subsequent panels', () => { + store.confirmPanel(CONSTANTS.MODEL_PARAMETER_PANEL.SPECIES_INFO) + store.editPanel(CONSTANTS.MODEL_PARAMETER_PANEL.SPECIES_INFO) + + expect(store.panelOpenStates.speciesInfo).to.equal(CONSTANTS.PANEL.OPEN) + expect(store.panelState.speciesInfo.confirmed).to.be.false + expect(store.panelState.speciesInfo.editable).to.be.true + + expect(store.panelOpenStates.siteInfo).to.equal(CONSTANTS.PANEL.CLOSE) + expect(store.panelState.siteInfo.confirmed).to.be.false + expect(store.panelState.siteInfo.editable).to.be.false + + expect(store.panelOpenStates.standDensity).to.equal(CONSTANTS.PANEL.CLOSE) + expect(store.panelState.standDensity.confirmed).to.be.false + expect(store.panelState.standDensity.editable).to.be.false + + expect(store.panelOpenStates.reportInfo).to.equal(CONSTANTS.PANEL.CLOSE) + expect(store.panelState.reportInfo.confirmed).to.be.false + expect(store.panelState.reportInfo.editable).to.be.false + }) + + it('should calculate total species percent correctly', () => { + store.speciesList = [ + { species: 'PL', percent: '40.0' }, + { species: 'AC', percent: '35.5' }, + { species: 'H', percent: '24.5' }, + { species: 'S', percent: null }, + { species: null, percent: null }, + { species: null, percent: null }, + ] + expect(store.totalSpeciesPercent).to.equal('100.0') + }) + + it('should update species groups correctly', () => { + store.speciesList = [ + { species: 'PL', percent: '50.0' }, + { species: 'PL', percent: '30.0' }, + { species: 'AC', percent: '20.0' }, + { species: null, percent: null }, + ] + store.updateSpeciesGroup() + + expect(store.speciesGroups.length).to.equal(2) + expect(store.speciesGroups[0].group).to.equal('PL') + expect(store.speciesGroups[0].percent).to.equal('80.0') + expect(store.speciesGroups[1].group).to.equal('AC') + expect(store.speciesGroups[1].percent).to.equal('20.0') + }) + + it('should set default values correctly', () => { + store.setDefaultValues() + + expect(store.derivedBy).to.equal(DEFAULTS.DEFAULT_VALUES.DERIVED_BY) + expect(store.speciesList[0].species).to.equal('PL') + expect(store.speciesList[0].percent).to.equal('30.0') + expect(store.becZone).to.equal(DEFAULTS.DEFAULT_VALUES.BEC_ZONE) + expect(store.startingAge).to.equal(DEFAULTS.DEFAULT_VALUES.STARTING_AGE) + }) + + it('should handle empty species list without errors', () => { + store.speciesList = [] + store.updateSpeciesGroup() + + expect(store.speciesGroups.length).to.equal(0) + expect(store.highestPercentSpecies).to.be.null + }) + + it('should enable run model button when all panels are confirmed', () => { + store.confirmPanel(CONSTANTS.MODEL_PARAMETER_PANEL.SPECIES_INFO) + store.confirmPanel(CONSTANTS.MODEL_PARAMETER_PANEL.SITE_INFO) + store.confirmPanel(CONSTANTS.MODEL_PARAMETER_PANEL.STAND_DENSITY) + store.confirmPanel(CONSTANTS.MODEL_PARAMETER_PANEL.REPORT_INFO) + + expect(store.runModelEnabled).to.be.true + }) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index acb18605..f8f2d4d1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "vdyp", - "version": "0.0.1.113", + "version": "0.0.1.114", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vdyp", - "version": "0.0.1.113", + "version": "0.0.1.114", "license": "(MIT OR Apache-2.0)", "dependencies": { "@bcgov/bc-sans": "2.1.0", @@ -44,6 +44,7 @@ "@storybook/vue3-vite": "8.4.7", "@types/lodash": "4.17.14", "@types/node": "20.14.5", + "@types/sinon": "17.0.3", "@vue/eslint-config-prettier": "9.0.0", "@vue/eslint-config-typescript": "13.0.0", "cypress": "13.17.0", @@ -54,6 +55,7 @@ "prettier": "3.3.3", "sass": "1.77.6", "sass-loader": "14.2.1", + "sinon": "19.0.2", "storybook": "8.4.7", "typescript": "5.6.3", "vite": "5.4.8", @@ -1249,6 +1251,50 @@ "integrity": "sha512-0HejFckBN2W+ucM6cUOlwsByTKt9/+0tWhqUffNIcHqCXkthY/mZ7AuYPK/2IIaGWhdl0h+tICDO0ssLMd6XMQ==", "dev": true }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true + }, "node_modules/@storybook/addon-actions": { "version": "8.4.7", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.4.7.tgz", @@ -1892,6 +1938,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/sinon": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", + "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", @@ -3493,6 +3548,15 @@ "node": ">=6" } }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -5397,6 +5461,12 @@ "setimmediate": "^1.0.5" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, "node_modules/jwt-decode": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", @@ -5500,6 +5570,13 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5791,6 +5868,19 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, "node_modules/node-html-parser": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-5.3.3.tgz", @@ -6109,6 +6199,15 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -7063,6 +7162,24 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/sinon": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -7465,6 +7582,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5f2fc0d8..b4466a97 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "vdyp", - "version": "0.0.1.113", + "version": "0.0.1.114", "private": true, "type": "module", "description": "Variable Density Yield Projection", @@ -55,6 +55,7 @@ "@storybook/vue3-vite": "8.4.7", "@types/lodash": "4.17.14", "@types/node": "20.14.5", + "@types/sinon": "17.0.3", "@vue/eslint-config-prettier": "9.0.0", "@vue/eslint-config-typescript": "13.0.0", "cypress": "13.17.0", @@ -65,6 +66,7 @@ "prettier": "3.3.3", "sass": "1.77.6", "sass-loader": "14.2.1", + "sinon": "19.0.2", "storybook": "8.4.7", "typescript": "5.6.3", "vite": "5.4.8",