diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 98b9e1d288..e8a3c3cdeb 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -117,6 +117,7 @@ { "label": "e2e Debug", "type": "shell", + "dependsOn": ["Aerie UI"], "command": "nvm use && npm run test:e2e:debug", "detail": "Task to run the e2e tests in debug mode." }, @@ -131,28 +132,16 @@ { "label": "e2e Tests", "type": "shell", - "dependsOn": ["Build UI"], + "dependsOn": ["Aerie UI"], "command": "nvm use && npm run test:e2e", "detail": "Task to run e2e tests" }, - { - "label": "e2e Rerun", - "type": "shell", - "command": "nvm use && npm run test:e2e", - "detail": "Task to rerun e2e tests without rebuilding the UI." - }, { "label": "e2e Tests - With UI", "type": "shell", - "dependsOn": ["Build UI"], + "dependsOn": ["Aerie UI"], "command": "nvm use && npm run test:e2e:with-ui", "detail": "Task to run e2e tests with Playwright UI." - }, - { - "label": "e2e Tests - With UI Rerun", - "type": "shell", - "command": "nvm use && npm run test:e2e:with-ui", - "detail": "Task to run e2e tests with Playwright UI without rebuilding the UI." } ] } diff --git a/docs/TESTING.md b/docs/TESTING.md index bb357b90f8..17211ebf02 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4,12 +4,14 @@ This document describes the testing development workflow. End-to-end tests are r ## End-to-end -All end-to-end tests assume a production build of the project is available: +All end-to-end tests assume a production build of the project is available if run from CI: ```sh npm run build ``` +If you are running the tests locally, then the above step is not needed. Playwright will be using your local dev server rather than starting up its own node server that uses the `/build` directory. + All end-to-end tests also assume all Aerie services are running and available on `localhost`. See the example [docker-compose-test.yml](../docker-compose-test.yml) for an example of how to run the complete Aerie system. Notice we disable authentication for simplicity when running our end-to-end tests. You can reference the [Aerie deployment documentation](https://github.com/NASA-AMMOS/aerie/tree/develop/deployment) for more detailed deployment information. To execute end-to-end tests normally (i.e. not in debug mode), use the following command: diff --git a/e2e-tests/data/banana-plan-export.json b/e2e-tests/data/banana-plan-export.json new file mode 100644 index 0000000000..6973d1a811 --- /dev/null +++ b/e2e-tests/data/banana-plan-export.json @@ -0,0 +1,11 @@ +{ + "activities": [], + "duration": "24:00:00", + "id": 59, + "model_id": 1, + "name": "Banana Plan", + "simulation_arguments": {}, + "start_time": "2024-08-02T00:00:00+00:00", + "tags": [], + "version": "2" +} diff --git a/e2e-tests/fixtures/Constraints.ts b/e2e-tests/fixtures/Constraints.ts index ad51549a79..286b49bee0 100644 --- a/e2e-tests/fixtures/Constraints.ts +++ b/e2e-tests/fixtures/Constraints.ts @@ -26,6 +26,11 @@ export class Constraints { async createConstraint(baseURL: string | undefined) { await expect(this.saveButton).toBeDisabled(); + + // TODO: Potentially fix this in component. The loading of monaco causes the page fields to reset + // so we need to wait until the page is fully loaded + await this.page.getByText('Loading Editor...').waitFor({ state: 'detached' }); + await this.fillConstraintName(); await this.fillConstraintDescription(); await this.fillConstraintDefinition(); diff --git a/e2e-tests/fixtures/Plans.ts b/e2e-tests/fixtures/Plans.ts index 211a596ad4..8fb3dc5eb9 100644 --- a/e2e-tests/fixtures/Plans.ts +++ b/e2e-tests/fixtures/Plans.ts @@ -11,7 +11,10 @@ export class Plans { createButton: Locator; durationDisplay: Locator; endTime: string = '2022-006T00:00:00'; + importButton: Locator; + importFilePath: string = 'e2e-tests/data/banana-plan-export.json'; inputEndTime: Locator; + inputFile: Locator; inputModel: Locator; inputModelSelector: string = 'select[name="model"]'; inputName: Locator; @@ -86,6 +89,12 @@ export class Plans { await this.inputEndTime.evaluate(e => e.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))); } + async fillInputFile(importFilePath: string = this.importFilePath) { + await this.inputFile.focus(); + await this.inputFile.setInputFiles(importFilePath); + await this.inputFile.evaluate(e => e.blur()); + } + async fillInputName(planName = this.planName) { await this.inputName.focus(); await this.inputName.fill(planName); @@ -131,6 +140,24 @@ export class Plans { await this.page.waitForTimeout(250); } + async importPlan(planName = this.planName) { + await expect(this.tableRow(planName)).not.toBeVisible(); + await this.importButton.click(); + await this.selectInputModel(); + await this.fillInputFile(); + await this.fillInputName(planName); + await this.createButton.waitFor({ state: 'attached' }); + await this.createButton.waitFor({ state: 'visible' }); + await this.createButton.isEnabled({ timeout: 500 }); + await this.createButton.click(); + await this.filterTable(planName); + await this.tableRow(planName).waitFor({ state: 'attached' }); + await this.tableRow(planName).waitFor({ state: 'visible' }); + const planId = await this.getPlanId(planName); + this.planId = planId; + return planId; + } + async selectInputModel() { const value = await getOptionValueFromText(this.page, this.inputModelSelector, this.models.modelName); await this.inputModel.focus(); @@ -148,7 +175,9 @@ export class Plans { this.confirmModalDeleteButton = this.confirmModal.getByRole('button', { name: 'Delete' }); this.createButton = page.getByRole('button', { name: 'Create' }); this.durationDisplay = page.locator('input[name="duration"]'); + this.importButton = page.getByRole('button', { name: 'Import' }); this.inputEndTime = page.locator('input[name="end-time"]'); + this.inputFile = page.locator('input[name="file"]'); this.inputModel = page.locator(this.inputModelSelector); this.inputName = page.locator('input[name="name"]'); this.inputStartTime = page.locator('input[name="start-time"]'); diff --git a/e2e-tests/fixtures/SchedulingConditions.ts b/e2e-tests/fixtures/SchedulingConditions.ts index 2ec3607647..dcde2b6842 100644 --- a/e2e-tests/fixtures/SchedulingConditions.ts +++ b/e2e-tests/fixtures/SchedulingConditions.ts @@ -27,6 +27,11 @@ export class SchedulingConditions { async createSchedulingCondition(baseURL: string | undefined) { await expect(this.saveButton).toBeDisabled(); + + // TODO: Potentially fix this in component. The loading of monaco causes the page fields to reset + // so we need to wait until the page is fully loaded + await this.page.getByText('Loading Editor...').waitFor({ state: 'detached' }); + await this.fillConditionName(); await this.fillConditionDescription(); await this.fillConditionDefinition(); diff --git a/e2e-tests/fixtures/SchedulingGoals.ts b/e2e-tests/fixtures/SchedulingGoals.ts index 57864bf1ef..9dfe5c15a5 100644 --- a/e2e-tests/fixtures/SchedulingGoals.ts +++ b/e2e-tests/fixtures/SchedulingGoals.ts @@ -24,6 +24,11 @@ export class SchedulingGoals { async createSchedulingGoal(baseURL: string | undefined, goalName: string) { await expect(this.saveButton).toBeDisabled(); + + // TODO: Potentially fix this in component. The loading of monaco causes the page fields to reset + // so we need to wait until the page is fully loaded + await this.page.getByText('Loading Editor...').waitFor({ state: 'detached' }); + await this.fillGoalName(goalName); await this.fillGoalDescription(); await this.fillGoalDefinition(); diff --git a/e2e-tests/tests/plans.test.ts b/e2e-tests/tests/plans.test.ts index c34fb033e4..06c13d1990 100644 --- a/e2e-tests/tests/plans.test.ts +++ b/e2e-tests/tests/plans.test.ts @@ -125,4 +125,9 @@ test.describe.serial('Plans', () => { test('Delete plan', async () => { await plans.deletePlan(); }); + + test('Import plan', async () => { + await plans.importPlan(); + await plans.deletePlan(); + }); }); diff --git a/package-lock.json b/package-lock.json index e3ab212284..3ad43a524e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nasa-jpl/aerie-ampcs": "^1.0.5", "@nasa-jpl/seq-json-schema": "^1.0.20", "@nasa-jpl/stellar": "^1.1.18", + "@streamparser/json": "^0.0.17", "@sveltejs/adapter-node": "5.0.1", "@sveltejs/kit": "^2.5.4", "ag-grid-community": "31.2.0", @@ -1503,6 +1504,11 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@streamparser/json": { + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.17.tgz", + "integrity": "sha512-mW54K6CTVJVLwXRB6kSS1xGWPmtTuXAStWnlvtesmcySgtop+eFPWOywBFPpJO4UD173epYsPSP6HSW8kuqN8w==" + }, "node_modules/@sveltejs/adapter-node": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.0.1.tgz", diff --git a/package.json b/package.json index 406a2ffa8c..c5377da7de 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@nasa-jpl/aerie-ampcs": "^1.0.5", "@nasa-jpl/seq-json-schema": "^1.0.20", "@nasa-jpl/stellar": "^1.1.18", + "@streamparser/json": "^0.0.17", "@sveltejs/adapter-node": "5.0.1", "@sveltejs/kit": "^2.5.4", "ag-grid-community": "31.2.0", diff --git a/playwright.config.ts b/playwright.config.ts index 6c68556930..3fa7f7125e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -49,6 +49,7 @@ const config: PlaywrightTestConfig = { webServer: { command: 'npm run preview', port: 3000, + reuseExistingServer: !process.env.CI, }, }; diff --git a/src/assets/export.svg b/src/assets/export.svg new file mode 100644 index 0000000000..999cc55e78 --- /dev/null +++ b/src/assets/export.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/assets/import.svg b/src/assets/import.svg new file mode 100644 index 0000000000..628093ca18 --- /dev/null +++ b/src/assets/import.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/components/menus/PlanMenu.svelte b/src/components/menus/PlanMenu.svelte index 49bcb25082..133dc839ab 100644 --- a/src/components/menus/PlanMenu.svelte +++ b/src/components/menus/PlanMenu.svelte @@ -14,8 +14,10 @@ import { showPlanBranchesModal, showPlanMergeRequestsModal } from '../../utilities/modal'; import { permissionHandler } from '../../utilities/permissionHandler'; import { featurePermissions } from '../../utilities/permissions'; + import { exportPlan } from '../../utilities/plan'; import Menu from '../menus/Menu.svelte'; import MenuItem from '../menus/MenuItem.svelte'; + import ProgressRadial from '../ui/ProgressRadial.svelte'; import MenuDivider from './MenuDivider.svelte'; export let plan: Plan; @@ -25,6 +27,8 @@ let hasCreatePlanBranchPermission: boolean = false; let hasCreateSnapshotPermission: boolean = false; let planMenu: Menu; + let planExportAbortController: AbortController | null = null; + let planExportProgress: number | null = null; $: hasCreateMergeRequestPermission = plan.parent_plan ? featurePermissions.planBranch.canCreateRequest( @@ -43,18 +47,50 @@ function createMergePlanBranchRequest() { effects.createPlanBranchRequest(plan, 'merge', user); + planMenu.hide(); } function createPlanBranch() { effects.createPlanBranch(plan, user); + planMenu.hide(); } function createPlanSnapshot() { effects.createPlanSnapshot(plan, user); + planMenu.hide(); + } + + async function onExportPlan() { + if (planExportAbortController) { + planExportAbortController.abort(); + } + + planExportProgress = 0; + planExportAbortController = new AbortController(); + + if (planExportAbortController && !planExportAbortController.signal.aborted) { + await exportPlan( + plan, + user, + (progress: number) => { + planExportProgress = progress; + }, + undefined, + planExportAbortController.signal, + ); + } + planExportProgress = null; + } + + function onCancelExportPlan() { + planExportAbortController?.abort(); + planExportAbortController = null; + planExportProgress = null; } function viewSnapshotHistory() { viewTogglePanel({ state: true, type: 'right', update: { rightComponentTop: 'PlanMetadataPanel' } }); + planMenu.hide(); } function showPlanBranches() { @@ -63,6 +99,7 @@ function showPlanMergeRequests() { showPlanMergeRequestsModal(user); + planMenu.hide(); } @@ -76,7 +113,7 @@
planMenu.toggle()}>
{plan.name}
- +
View Snapshot History
+ + + {#if planExportProgress === null} + Export plan as .json + {:else} +
+ Cancel plan export + +
+ {/if} +
{#if plan.child_plans.length > 0} @@ -193,4 +241,11 @@ cursor: pointer; user-select: none; } + + .cancel-plan-export { + --progress-radial-background: var(--st-gray-20); + align-items: center; + column-gap: 0.25rem; + display: flex; + } diff --git a/src/components/plan/PlanForm.svelte b/src/components/plan/PlanForm.svelte index b5e39fa4e3..b6566955d3 100644 --- a/src/components/plan/PlanForm.svelte +++ b/src/components/plan/PlanForm.svelte @@ -2,7 +2,7 @@ @@ -245,18 +187,27 @@
- + + {:else} + + {/if}
@@ -460,15 +411,24 @@ .export { border-radius: 50%; + height: 28px; position: relative; + width: 28px; } - .export .cancel { - display: none; + + .cancel-button { + --progress-radial-background: var(--st-gray-20); + background: none; + border: 0; + border-radius: 50%; + position: relative; + width: 28px; } - .export:hover .cancel { + .cancel-button .cancel { align-items: center; - display: flex; + cursor: pointer; + display: none; height: 100%; justify-content: center; left: 0; @@ -476,4 +436,12 @@ top: 0; width: 100%; } + + .cancel-button .cancel :global(svg) { + width: 10px; + } + + .cancel-button:hover .cancel { + display: flex; + } diff --git a/src/components/ui/DataGrid/DataGridActions.svelte b/src/components/ui/DataGrid/DataGridActions.svelte index a6c8990f13..f1c908bd6e 100644 --- a/src/components/ui/DataGrid/DataGridActions.svelte +++ b/src/components/ui/DataGrid/DataGridActions.svelte @@ -3,13 +3,16 @@ {#if viewCallback} {/if} {#if downloadCallback} - + {#if downloadProgress === null} + + {:else} + + {/if} {/if} {#if editCallback} {/if} + + diff --git a/src/components/ui/ProgressRadial.svelte b/src/components/ui/ProgressRadial.svelte index f47911d37e..64474e5b68 100644 --- a/src/components/ui/ProgressRadial.svelte +++ b/src/components/ui/ProgressRadial.svelte @@ -1,6 +1,7 @@ @@ -532,170 +664,288 @@ - New Plan + {#if selectedPlan} + Selected plan +
+
+ {#if planExportProgress !== null} + + {/if} + +
+ +
+ {:else} + New Plan + + {/if}
-
- - + {#if selectedPlan} +
- - +
+ {:else} + + - - - - - {#if selectedModel} -
- -
- {/if} - - - - - - -
-
-
- -
- -
- - -
+ }} + on:change={onPlanFileChange} + /> +
+ {#if planUploadFilesError} +
{planUploadFilesError}
+ {/if} +
- - - + {/each} - {/if} - - + + + {#if selectedModel} +
+ +
+ {/if} -
- - -
+ + + + -
- -
- +
+ +
+
+ +
+ +
+ + +
+ + + + + + +
+ + +
+ +
+ +
+ + {/if} @@ -718,8 +968,9 @@ itemDisplayText="Plan" items={filteredPlans} {user} + selectedItemId={selectedPlanId ?? null} on:deleteItem={event => deletePlanContext(event, filteredPlans)} - on:rowClicked={({ detail }) => showPlan(detail.data)} + on:rowClicked={({ detail }) => selectPlan(detail.data.id)} /> {:else} No Plans Found @@ -733,4 +984,88 @@ .model-status { padding: 5px 16px 0; } + + .plan-import-container { + background: var(--st-gray-15); + border-radius: 5px; + margin: 5px; + padding: 8px 11px 8px; + position: relative; + } + + .plan-import-container[hidden] { + display: none; + } + + .transfer-button-container { + display: grid; + grid-template-columns: auto auto; + position: relative; + } + + .transfer-button { + column-gap: 4px; + position: relative; + } + + .cancel-button { + --progress-radial-background: var(--st-gray-20); + background: none; + border: 0; + position: relative; + } + + .cancel-button .cancel { + align-items: center; + cursor: pointer; + display: none; + height: 100%; + justify-content: center; + left: 0; + position: absolute; + top: 0; + width: 100%; + } + + .cancel-button .cancel :global(svg) { + width: 10px; + } + + .cancel-button:hover .cancel { + display: flex; + } + + .import-input-container { + column-gap: 0.5rem; + display: grid; + grid-template-columns: auto min-content; + } + + .close-import { + background: none; + border: 0; + cursor: pointer; + height: 1.3rem; + padding: 0; + position: absolute; + right: 3px; + top: 3px; + } + + .error { + color: var(--st-red); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .selected-plan-buttons { + column-gap: 0.25rem; + display: grid; + grid-template-columns: repeat(2, min-content); + } + + .plan-metadata-item-label { + white-space: nowrap; + } diff --git a/src/tests/mocks/user/mockUser.ts b/src/tests/mocks/user/mockUser.ts new file mode 100644 index 0000000000..9b473010a7 --- /dev/null +++ b/src/tests/mocks/user/mockUser.ts @@ -0,0 +1,105 @@ +import type { User } from '../../../types/app'; + +export const mockUser: User = { + activeRole: 'aerie_admin', + allowedRoles: ['aerie_admin'], + defaultRole: 'aerie_admin', + id: 'test', + permissibleQueries: { + activity_presets: true, + apply_preset_to_activity: true, + begin_merge: true, + cancel_merge: true, + commit_merge: true, + constraint: true, + constraintViolations: true, + createExpansionSet: true, + create_merge_request: true, + delete_activity_by_pk_delete_subtree_bulk: true, + delete_activity_by_pk_reanchor_plan_start_bulk: true, + delete_activity_by_pk_reanchor_to_anchor_bulk: true, + delete_activity_directive: true, + delete_activity_directive_tags: true, + delete_activity_presets_by_pk: true, + delete_command_dictionary_by_pk: true, + delete_constraint_by_pk: true, + delete_constraint_tags: true, + delete_expansion_rule_by_pk: true, + delete_expansion_rule_tags: true, + delete_expansion_set_by_pk: true, + delete_mission_model_by_pk: true, + delete_plan_by_pk: true, + delete_plan_tags: true, + delete_preset_to_directive_by_pk: true, + delete_scheduling_condition_by_pk: true, + delete_scheduling_goal_by_pk: true, + delete_scheduling_goal_tags: true, + delete_scheduling_specification: true, + delete_scheduling_specification_goals_by_pk: true, + delete_sequence_by_pk: true, + delete_sequence_to_simulated_activity_by_pk: true, + delete_simulation_template_by_pk: true, + delete_tags_by_pk: true, + delete_user_sequence_by_pk: true, + delete_view: true, + delete_view_by_pk: true, + deny_merge: true, + duplicate_plan: true, + expandAllActivities: true, + expansion_rule: true, + expansion_run: true, + expansion_set: true, + insert_activity_directive_one: true, + insert_activity_directive_tags: true, + insert_activity_presets_one: true, + insert_constraint_one: true, + insert_constraint_tags: true, + insert_expansion_rule_one: true, + insert_expansion_rule_tags: true, + insert_mission_model_one: true, + insert_plan_one: true, + insert_plan_tags: true, + insert_scheduling_condition_one: true, + insert_scheduling_goal_one: true, + insert_scheduling_goal_tags: true, + insert_scheduling_specification_conditions_one: true, + insert_scheduling_specification_goals_one: true, + insert_scheduling_specification_one: true, + insert_sequence_one: true, + insert_sequence_to_simulated_activity_one: true, + insert_simulation_template_one: true, + insert_tags: true, + insert_user_sequence_one: true, + insert_view_one: true, + mission_model: true, + plan_by_pk: true, + set_resolution: true, + set_resolution_bulk: true, + simulate: true, + simulation: true, + simulation_template: true, + tag: true, + update_activity_directive_by_pk: true, + update_activity_presets_by_pk: true, + update_constraint_by_pk: true, + update_expansion_rule_by_pk: true, + update_plan_by_pk: true, + update_scheduling_condition_by_pk: true, + update_scheduling_goal_by_pk: true, + update_scheduling_specification_by_pk: true, + update_scheduling_specification_conditions_by_pk: true, + update_scheduling_specification_goals_by_pk: true, + update_simulation: true, + update_simulation_by_pk: true, + update_simulation_template_by_pk: true, + update_tags_by_pk: true, + update_user_sequence_by_pk: true, + update_view_by_pk: true, + uploadDictionary: true, + user_sequence: true, + view: true, + withdraw_merge_request: true, + }, + rolePermissions: {}, + token: '', +}; diff --git a/src/types/plan.ts b/src/types/plan.ts index f760835cdb..a77f573168 100644 --- a/src/types/plan.ts +++ b/src/types/plan.ts @@ -107,6 +107,7 @@ export type PlanTransfer = Pick[]; simulation_arguments: ArgumentsMap; tags?: { tag: Pick }[]; + version?: string; }; export type DeprecatedPlanTransfer = Omit & { diff --git a/src/utilities/effects.test.ts b/src/utilities/effects.test.ts index c9c5068f06..db8682e4c5 100644 --- a/src/utilities/effects.test.ts +++ b/src/utilities/effects.test.ts @@ -1,6 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import * as Errors from '../stores/errors'; -import type { User } from '../types/app'; +import { mockUser } from '../tests/mocks/user/mockUser'; import type { Model } from '../types/model'; import type { ArgumentsMap, ParametersMap } from '../types/parameter'; import type { Plan } from '../types/plan'; @@ -18,110 +18,6 @@ vi.mock('./toast', () => ({ const catchErrorSpy = vi.fn(); -const user: User = { - activeRole: 'aerie_admin', - allowedRoles: ['aerie_admin'], - defaultRole: 'aerie_admin', - id: 'test', - permissibleQueries: { - activity_presets: true, - apply_preset_to_activity: true, - begin_merge: true, - cancel_merge: true, - commit_merge: true, - constraint: true, - constraintViolations: true, - createExpansionSet: true, - create_merge_request: true, - delete_activity_by_pk_delete_subtree_bulk: true, - delete_activity_by_pk_reanchor_plan_start_bulk: true, - delete_activity_by_pk_reanchor_to_anchor_bulk: true, - delete_activity_directive: true, - delete_activity_directive_tags: true, - delete_activity_presets_by_pk: true, - delete_command_dictionary_by_pk: true, - delete_constraint_by_pk: true, - delete_constraint_tags: true, - delete_expansion_rule_by_pk: true, - delete_expansion_rule_tags: true, - delete_expansion_set_by_pk: true, - delete_mission_model_by_pk: true, - delete_plan_by_pk: true, - delete_plan_tags: true, - delete_preset_to_directive_by_pk: true, - delete_scheduling_condition_by_pk: true, - delete_scheduling_goal_by_pk: true, - delete_scheduling_goal_tags: true, - delete_scheduling_specification: true, - delete_scheduling_specification_goals_by_pk: true, - delete_sequence_by_pk: true, - delete_sequence_to_simulated_activity_by_pk: true, - delete_simulation_template_by_pk: true, - delete_tags_by_pk: true, - delete_user_sequence_by_pk: true, - delete_view: true, - delete_view_by_pk: true, - deny_merge: true, - duplicate_plan: true, - expandAllActivities: true, - expansion_rule: true, - expansion_run: true, - expansion_set: true, - insert_activity_directive_one: true, - insert_activity_directive_tags: true, - insert_activity_presets_one: true, - insert_constraint_one: true, - insert_constraint_tags: true, - insert_expansion_rule_one: true, - insert_expansion_rule_tags: true, - insert_mission_model_one: true, - insert_plan_one: true, - insert_plan_tags: true, - insert_scheduling_condition_one: true, - insert_scheduling_goal_one: true, - insert_scheduling_goal_tags: true, - insert_scheduling_specification_conditions_one: true, - insert_scheduling_specification_goals_one: true, - insert_scheduling_specification_one: true, - insert_sequence_one: true, - insert_sequence_to_simulated_activity_one: true, - insert_simulation_template_one: true, - insert_tags: true, - insert_user_sequence_one: true, - insert_view_one: true, - mission_model: true, - plan_by_pk: true, - set_resolution: true, - set_resolution_bulk: true, - simulate: true, - simulation: true, - simulation_template: true, - tag: true, - update_activity_directive_by_pk: true, - update_activity_presets_by_pk: true, - update_constraint_by_pk: true, - update_expansion_rule_by_pk: true, - update_plan_by_pk: true, - update_scheduling_condition_by_pk: true, - update_scheduling_goal_by_pk: true, - update_scheduling_specification_by_pk: true, - update_scheduling_specification_conditions_by_pk: true, - update_scheduling_specification_goals_by_pk: true, - update_simulation: true, - update_simulation_by_pk: true, - update_simulation_template_by_pk: true, - update_tags_by_pk: true, - update_user_sequence_by_pk: true, - update_view_by_pk: true, - uploadDictionary: true, - user_sequence: true, - view: true, - withdraw_merge_request: true, - }, - rolePermissions: {}, - token: '', -}; - describe('Handle modal and requests in effects', () => { beforeAll(() => { vi.mock('../stores/plan', () => ({ @@ -167,7 +63,7 @@ describe('Handle modal and requests in effects', () => { owner: 'test', } as Plan, 4, - user, + mockUser, ); expect(catchErrorSpy).toHaveBeenCalledWith( @@ -206,7 +102,7 @@ describe('Handle modal and requests in effects', () => { owner: 'test', } as Plan, 3, - user, + mockUser, ); expect(catchErrorSpy).toHaveBeenCalledWith( @@ -230,7 +126,7 @@ describe('Handle modal and requests in effects', () => { id: 1, owner: 'test', } as Plan, - user, + mockUser, ); expect(catchErrorSpy).toHaveBeenCalledWith( @@ -258,7 +154,7 @@ describe('Handle modal and requests in effects', () => { id: 1, owner: 'test', } as Plan, - user, + mockUser, ); expect(catchErrorSpy).toHaveBeenCalledWith( @@ -276,7 +172,7 @@ describe('Handle modal and requests in effects', () => { vi.spyOn(Errors, 'catchError').mockImplementationOnce(catchErrorSpy); - await effects.createActivityDirectiveTags([], user); + await effects.createActivityDirectiveTags([], mockUser); expect(catchErrorSpy).toHaveBeenCalledWith( 'Create Activity Directive Tags Failed', @@ -301,7 +197,7 @@ describe('Handle modal and requests in effects', () => { tag_id: 1, }, ], - user, + mockUser, ); expect(catchErrorSpy).toHaveBeenCalledOnce(); diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index 25c9dc7f5e..f952ac083d 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -2721,6 +2721,23 @@ const effects = { } }, + async getActivitiesForPlan(planId: number, user: User | null): Promise { + try { + const query = convertToQuery(gql.SUB_ACTIVITY_DIRECTIVES); + const data = await reqHasura(query, { planId }, user); + + const { activity_directives } = data; + if (activity_directives != null) { + return activity_directives; + } else { + throw Error('Unable to retrieve activities for plan'); + } + } catch (e) { + catchError(e as Error); + return []; + } + }, + async getActivityDirectiveChangelog( planId: number, activityId: number, @@ -3322,6 +3339,82 @@ const effects = { } }, + async getQualifiedPlanParts( + planId: number, + user: User | null, + progressCallback: (progress: number) => void, + signal?: AbortSignal, + ): Promise<{ + activities: ActivityDirectiveDB[]; + plan: Plan; + simulationArguments: ArgumentsMap; + } | null> { + try { + const plan = await effects.getPlan(planId, user); + + if (plan) { + const simulation: Simulation | null = await effects.getPlanLatestSimulation(plan.id, user); + const simulationArguments: ArgumentsMap = simulation + ? { + ...simulation.template?.arguments, + ...simulation.arguments, + } + : {}; + + const activities: ActivityDirectiveDB[] = (await effects.getActivitiesForPlan(plan.id, user)) ?? []; + + let totalProgress = 0; + const numOfDirectives = activities.length; + + const qualifiedActivityDirectives = ( + await Promise.all( + activities.map(async activityDirective => { + if (plan) { + const effectiveArguments = await effects.getEffectiveActivityArguments( + plan?.model_id, + activityDirective.type, + activityDirective.arguments, + user, + signal, + ); + + totalProgress++; + progressCallback((totalProgress / numOfDirectives) * 100); + + return { + ...activityDirective, + arguments: effectiveArguments?.arguments ?? activityDirective.arguments, + }; + } + + totalProgress++; + progressCallback((totalProgress / numOfDirectives) * 100); + + return activityDirective; + }), + ) + ).sort((directiveA, directiveB) => { + if (directiveA.id < directiveB.id) { + return -1; + } + if (directiveA.id > directiveB.id) { + return 1; + } + return 0; + }); + + return { + activities: qualifiedActivityDirectives, + plan, + simulationArguments, + }; + } + } catch (e) { + catchError(e as Error); + } + return null; + }, + getResource( datasetId: number, name: string, @@ -3832,6 +3925,9 @@ const effects = { if (!gatewayPermissions.IMPORT_PLAN(user)) { throwPermissionError('import a plan'); } + + creatingPlan.set(true); + const file: File = files[0]; const duration = getIntervalFromDoyRange(startTime, endTime); @@ -3849,6 +3945,7 @@ const effects = { const createdPlan = await reqGateway('/importPlan', 'POST', body, user, true); + creatingPlan.set(false); if (createdPlan != null) { return createdPlan; } @@ -3856,6 +3953,7 @@ const effects = { return null; } catch (e) { catchError(e as Error); + creatingPlan.set(false); return null; } }, diff --git a/src/utilities/generic.test.ts b/src/utilities/generic.test.ts index fe07bcbb86..3d612e4439 100644 --- a/src/utilities/generic.test.ts +++ b/src/utilities/generic.test.ts @@ -1,6 +1,14 @@ import { afterAll, describe, expect, test, vi } from 'vitest'; import { SearchParameters } from '../enums/searchParameters'; -import { attemptStringConversion, clamp, classNames, filterEmpty, getSearchParameterNumber, isMacOs } from './generic'; +import { + attemptStringConversion, + clamp, + classNames, + filterEmpty, + getSearchParameterNumber, + isMacOs, + parseJSONStream, +} from './generic'; const mockNavigator = { platform: 'MacIntel', @@ -8,140 +16,163 @@ const mockNavigator = { vi.stubGlobal('navigator', mockNavigator); -afterAll(() => { - vi.restoreAllMocks(); -}); - -describe('clamp', () => { - test('Should clamp a number already in the correct range to the number itself', () => { - const clampedNumber = clamp(10, 0, 20); - expect(clampedNumber).toEqual(10); - }); - - test('Should clamp a number smaller than the correct range to the lower bound in the range', () => { - const clampedNumber = clamp(5, 10, 20); - expect(clampedNumber).toEqual(10); - }); - - test('Should clamp a number larger than the correct range to the upper bound in the range', () => { - const clampedNumber = clamp(25, 10, 20); - expect(clampedNumber).toEqual(20); - }); -}); - -describe('classNames', () => { - test('Should generate the correct complete class string given an object of conditionals', () => { - expect( - classNames('foo', { - bar: true, - baz: false, - }), - ).toEqual('foo bar'); - - expect( - classNames('foo', { - bar: true, - baz: true, - }), - ).toEqual('foo bar baz'); - - expect( - classNames('foo', { - bar: false, - baz: false, - }), - ).toEqual('foo'); - }); -}); - -describe('filterEmpty', () => { - test('Should correctly determine if something is not null or undefined', () => { - expect(filterEmpty(0)).toEqual(true); - expect(filterEmpty(false)).toEqual(true); - expect(filterEmpty(null)).toEqual(false); - expect(filterEmpty(undefined)).toEqual(false); - }); - - test('Should correctly filter out null and undefined entries in arrays', () => { - expect([0, 1, 2, null, 4, undefined, 5].filter(filterEmpty)).toStrictEqual([0, 1, 2, 4, 5]); - expect(['false', false, { foo: 1 }, null, undefined].filter(filterEmpty)).toStrictEqual([ - 'false', - false, - { foo: 1 }, - ]); - }); -}); - -describe('attemptStringConversion', () => { - test('Should convert strings to strings', () => { - expect(attemptStringConversion('')).toEqual(''); - expect(attemptStringConversion('Foo')).toEqual('Foo'); - }); - test('Should convert numbers to strings', () => { - expect(attemptStringConversion(1.0101)).toEqual('1.0101'); - }); - test('Should convert arrays to strings', () => { - expect(attemptStringConversion([1.0101, 'Foo'])).toEqual('1.0101,Foo'); - }); - test('Should convert booleans to strings', () => { - expect(attemptStringConversion(true)).toEqual('true'); - expect(attemptStringConversion(false)).toEqual('false'); - }); - test('Should return null when attempting to convert non-stringable values', () => { - expect(attemptStringConversion(null)).toEqual(null); - expect(attemptStringConversion(undefined)).toEqual(null); - }); -}); - -describe('isMacOs', () => { - test('Should return true for Mac browsers', () => { - expect(isMacOs()).toEqual(true); - - mockNavigator.platform = 'MacPPC'; - expect(isMacOs()).toEqual(true); - - mockNavigator.platform = 'Mac68K'; - expect(isMacOs()).toEqual(true); - }); - - test('Should return false for Windows browsers', () => { - mockNavigator.platform = 'Win32'; - expect(isMacOs()).toEqual(false); - - mockNavigator.platform = 'Windows'; - expect(isMacOs()).toEqual(false); - }); - - test('Should return false for Linux browsers', () => { - mockNavigator.platform = 'Linux i686'; - expect(isMacOs()).toEqual(false); - - mockNavigator.platform = 'Linux x86_64'; - expect(isMacOs()).toEqual(false); - }); -}); - -describe('getSearchParameterNumber', () => { - test.each( - Object.keys(SearchParameters).map(key => ({ - key, - parameter: SearchParameters[key as keyof typeof SearchParameters], - })), - )('Should correctly parse out the $key specified in the URL search query parameter', ({ parameter }) => { - const random = Math.random(); - expect( - getSearchParameterNumber(parameter as SearchParameters, new URLSearchParams(`?${parameter}=${random}`)), - ).toBe(random); - }); - - test.each( - Object.keys(SearchParameters).map(key => ({ - key, - parameter: SearchParameters[key as keyof typeof SearchParameters], - })), - )('Should ignore non number values for the $key specified in the URL search query parameter', ({ parameter }) => { - expect(getSearchParameterNumber(parameter as SearchParameters, new URLSearchParams(`?${parameter}=foo`))).toBe( - null, - ); +describe('Generic utility function tests', () => { + afterAll(() => { + vi.restoreAllMocks(); + }); + + describe('clamp', () => { + test('Should clamp a number already in the correct range to the number itself', () => { + const clampedNumber = clamp(10, 0, 20); + expect(clampedNumber).toEqual(10); + }); + + test('Should clamp a number smaller than the correct range to the lower bound in the range', () => { + const clampedNumber = clamp(5, 10, 20); + expect(clampedNumber).toEqual(10); + }); + + test('Should clamp a number larger than the correct range to the upper bound in the range', () => { + const clampedNumber = clamp(25, 10, 20); + expect(clampedNumber).toEqual(20); + }); + }); + + describe('classNames', () => { + test('Should generate the correct complete class string given an object of conditionals', () => { + expect( + classNames('foo', { + bar: true, + baz: false, + }), + ).toEqual('foo bar'); + + expect( + classNames('foo', { + bar: true, + baz: true, + }), + ).toEqual('foo bar baz'); + + expect( + classNames('foo', { + bar: false, + baz: false, + }), + ).toEqual('foo'); + }); + }); + + describe('filterEmpty', () => { + test('Should correctly determine if something is not null or undefined', () => { + expect(filterEmpty(0)).toEqual(true); + expect(filterEmpty(false)).toEqual(true); + expect(filterEmpty(null)).toEqual(false); + expect(filterEmpty(undefined)).toEqual(false); + }); + + test('Should correctly filter out null and undefined entries in arrays', () => { + expect([0, 1, 2, null, 4, undefined, 5].filter(filterEmpty)).toStrictEqual([0, 1, 2, 4, 5]); + expect(['false', false, { foo: 1 }, null, undefined].filter(filterEmpty)).toStrictEqual([ + 'false', + false, + { foo: 1 }, + ]); + }); + }); + + describe('attemptStringConversion', () => { + test('Should convert strings to strings', () => { + expect(attemptStringConversion('')).toEqual(''); + expect(attemptStringConversion('Foo')).toEqual('Foo'); + }); + test('Should convert numbers to strings', () => { + expect(attemptStringConversion(1.0101)).toEqual('1.0101'); + }); + test('Should convert arrays to strings', () => { + expect(attemptStringConversion([1.0101, 'Foo'])).toEqual('1.0101,Foo'); + }); + test('Should convert booleans to strings', () => { + expect(attemptStringConversion(true)).toEqual('true'); + expect(attemptStringConversion(false)).toEqual('false'); + }); + test('Should return null when attempting to convert non-stringable values', () => { + expect(attemptStringConversion(null)).toEqual(null); + expect(attemptStringConversion(undefined)).toEqual(null); + }); + }); + + describe('isMacOs', () => { + test('Should return true for Mac browsers', () => { + expect(isMacOs()).toEqual(true); + + mockNavigator.platform = 'MacPPC'; + expect(isMacOs()).toEqual(true); + + mockNavigator.platform = 'Mac68K'; + expect(isMacOs()).toEqual(true); + }); + + test('Should return false for Windows browsers', () => { + mockNavigator.platform = 'Win32'; + expect(isMacOs()).toEqual(false); + + mockNavigator.platform = 'Windows'; + expect(isMacOs()).toEqual(false); + }); + + test('Should return false for Linux browsers', () => { + mockNavigator.platform = 'Linux i686'; + expect(isMacOs()).toEqual(false); + + mockNavigator.platform = 'Linux x86_64'; + expect(isMacOs()).toEqual(false); + }); + }); + + describe('getSearchParameterNumber', () => { + test.each( + Object.keys(SearchParameters).map(key => ({ + key, + parameter: SearchParameters[key as keyof typeof SearchParameters], + })), + )('Should correctly parse out the $key specified in the URL search query parameter', ({ parameter }) => { + const random = Math.random(); + expect( + getSearchParameterNumber(parameter as SearchParameters, new URLSearchParams(`?${parameter}=${random}`)), + ).toBe(random); + }); + + test.each( + Object.keys(SearchParameters).map(key => ({ + key, + parameter: SearchParameters[key as keyof typeof SearchParameters], + })), + )('Should ignore non number values for the $key specified in the URL search query parameter', ({ parameter }) => { + expect(getSearchParameterNumber(parameter as SearchParameters, new URLSearchParams(`?${parameter}=foo`))).toBe( + null, + ); + }); + }); + + describe('parseJSONStream', () => { + test('Should be able to parse a really long JSON string', async () => { + const { readable, writable } = new TransformStream(); + + const writer = writable.getWriter(); + await writer.ready; + writer.write('{"activities":['); + const numOfActivities = 5000; + for (let i = 0; i < numOfActivities; i++) { + writer.write(JSON.stringify({ arguments: { metadata: {}, name: 'PeelBanana', peelDirection: 'fromTip' } })); + if (i < numOfActivities - 1) { + writer.write(','); + } + } + writer.write(']}'); + writer.close(); + + expect(await parseJSONStream(readable as unknown as ReadableStream)).toBeTypeOf('object'); + }); }); }); diff --git a/src/utilities/generic.ts b/src/utilities/generic.ts index 19ec370f6a..841311e30d 100644 --- a/src/utilities/generic.ts +++ b/src/utilities/generic.ts @@ -1,4 +1,5 @@ import { browser } from '$app/environment'; +import JSONParser from '@streamparser/json/jsonparser.js'; import type { SearchParameters } from '../enums/searchParameters'; /** @@ -348,3 +349,81 @@ export function addPageFocusListener(onChange: (string: 'out' | 'in') => void): document.removeEventListener('visibilitychange', handleChange); }; } + +/** + * Utility function for downloading the contents of a Blob to client storage + * @param blob + * @param filename - file extension should be provided + */ +export function downloadBlob(blob: Blob, filename: string) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); +} + +/** + * Utility function for downloading a valid JSON to client storage + * @param json + * @param filename - file extension doesn't need to be provided + */ +export function downloadJSON(json: object, filename: string) { + downloadBlob( + new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' }), + /\.json$/.test(filename) ? filename : `${filename}.json`, + ); +} + +async function* streamAsyncIterable(stream: ReadableStream) { + const reader = stream.getReader(); + try { + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) { + return; + } + yield value; + } + } finally { + reader.releaseLock(); + } +} + +/** + * Utility function for parsing a large JSON string into a JSON object + * This function is more for very long strings that need to be broken up into chunks in order to + * fully parse it into a JSON object without running out of memory + * @param jsonStream + * @returns R + */ +export async function parseJSONStream(jsonStream: ReadableStream): Promise { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const jsonParser = new JSONParser({ paths: ['$.*'], stringBufferSize: undefined }); + let finalJSON: R; + jsonParser.onToken = ({ value }) => { + if (finalJSON === undefined) { + if (value === '[') { + finalJSON = [] as R; + } else if (value === '{') { + finalJSON = {} as R; + } + } + }; + jsonParser.onValue = ({ parent }) => { + finalJSON = parent as R; + }; + jsonParser.onEnd = () => { + resolve(finalJSON as R); + }; + + try { + for await (const result of streamAsyncIterable(jsonStream)) { + jsonParser.write(result); + } + } catch (e) { + reject(e); + } + }); +} diff --git a/src/utilities/plan.test.ts b/src/utilities/plan.test.ts index 328b188576..e0609129d2 100644 --- a/src/utilities/plan.test.ts +++ b/src/utilities/plan.test.ts @@ -1,12 +1,36 @@ import { describe } from 'node:test'; -import { expect, it } from 'vitest'; -import { getPlanForTransfer } from './plan'; +import { expect, it, vi } from 'vitest'; +import { mockUser } from '../tests/mocks/user/mockUser'; +import type { ActivityDirective } from '../types/activity'; +import type { Plan } from '../types/plan'; +import type { Simulation } from '../types/simulation'; +import effects from './effects'; +import * as generic from './generic'; +import * as plan from './plan'; + +vi.mock('./effects', () => ({ + default: { + getActivitiesForPlan: vi.fn(), + getEffectiveActivityArguments: vi.fn(), + getPlanLatestSimulation: vi.fn(), + }, +})); + +const getPlanLatestSimulationSpy = vi.spyOn(effects, 'getPlanLatestSimulation'); +const getActivitiesForPlanSpy = vi.spyOn(effects, 'getActivitiesForPlan'); +const getEffectiveActivityArgumentsSpy = vi.spyOn(effects, 'getEffectiveActivityArguments'); describe('Plan utility', () => { describe('getPlanForTransfer', () => { - it('Should return a formatted plan object for downloading', () => { + it('Should return a formatted plan object for downloading', async () => { + getPlanLatestSimulationSpy.mockResolvedValueOnce({ + arguments: { + test: 1, + }, + } as unknown as Simulation); + expect( - getPlanForTransfer( + await plan.getPlanForTransfer( { child_plans: [], collaborators: [], @@ -67,6 +91,8 @@ describe('Plan utility', () => { updated_at: '2024-01-01T00:00:00', updated_by: 'test', }, + mockUser, + () => {}, [ { anchor_id: null, @@ -99,9 +125,158 @@ describe('Plan utility', () => { type: 'TestActivity', }, ], + ), + ).toEqual({ + activities: [ + { + anchor_id: null, + anchored_to_start: true, + arguments: { + numOfTests: 1, + }, + id: 0, + metadata: {}, + name: 'Test Activity', + start_offset: '0:00:00', + tags: [ + { + tag: { + color: '#ff0000', + name: 'test tag', + }, + }, + ], + type: 'TestActivity', + }, + ], + duration: '1y', + id: 1, + model_id: 1, + name: 'Foo plan', + simulation_arguments: { + test: 1, + }, + start_time: '2024-01-01T00:00:00+00:00', + tags: [ { - test: 1, + tag: { + color: '#fff', + name: 'test tag', + }, }, + ], + version: '2', + }); + }); + + it('Should download all activities for a plan and return a formatted plan object for downloading', async () => { + getPlanLatestSimulationSpy.mockResolvedValueOnce({ + arguments: { + test: 1, + }, + } as unknown as Simulation); + getActivitiesForPlanSpy.mockResolvedValueOnce([ + { + anchor_id: null, + anchored_to_start: true, + arguments: {}, + created_at: '2024-01-01T00:00:00', + created_by: 'test', + id: 0, + last_modified_arguments_at: '2024-01-01T00:00:00', + last_modified_at: '2024-01-01T00:00:00', + metadata: {}, + name: 'Test Activity', + plan_id: 1, + source_scheduling_goal_id: null, + start_offset: '0:00:00', + start_time_ms: 0, + tags: [ + { + tag: { + color: '#ff0000', + created_at: '', + id: 1, + name: 'test tag', + owner: 'test', + }, + }, + ], + type: 'TestActivity', + }, + ] as ActivityDirective[]); + getEffectiveActivityArgumentsSpy.mockResolvedValueOnce({ + arguments: { + numOfTests: 1, + }, + errors: {}, + success: true, + }); + + expect( + await plan.getPlanForTransfer( + { + child_plans: [], + collaborators: [], + constraint_specification: [], + created_at: '2024-01-01T00:00:00', + duration: '1y', + end_time_doy: '2025-001T00:00:00', + id: 1, + is_locked: false, + model: { + constraint_specification: [], + created_at: '2024-01-01T00:00:00', + id: 1, + jar_id: 123, + mission: 'Test', + name: 'Test Model', + owner: 'test', + parameters: { parameters: {} }, + plans: [], + refresh_activity_type_logs: [], + refresh_model_parameter_logs: [], + refresh_resource_type_logs: [], + scheduling_specification_conditions: [], + scheduling_specification_goals: [], + version: '1.0.0', + view: null, + }, + model_id: 1, + name: 'Foo plan', + owner: 'test', + parent_plan: null, + revision: 1, + scheduling_specification: null, + simulations: [ + { + id: 3, + simulation_datasets: [ + { + id: 1, + plan_revision: 1, + }, + ], + }, + ], + start_time: '2024-01-01T00:00:00+00:00', + start_time_doy: '2024-001T00:00:00', + tags: [ + { + tag: { + color: '#fff', + created_at: '2024-01-01T00:00:00', + id: 0, + name: 'test tag', + owner: 'test', + }, + }, + ], + updated_at: '2024-01-01T00:00:00', + updated_by: 'test', + }, + mockUser, + () => {}, ), ).toEqual({ activities: [ @@ -142,7 +317,109 @@ describe('Plan utility', () => { }, }, ], + version: '2', }); }); }); + + describe('exportPlan', () => { + const mockPlan: Plan = { + child_plans: [], + collaborators: [], + constraint_specification: [], + created_at: '2024-01-01T00:00:00', + duration: '1y', + end_time_doy: '2025-001T00:00:00', + id: 1, + is_locked: false, + model: { + constraint_specification: [], + created_at: '2024-01-01T00:00:00', + id: 1, + jar_id: 123, + mission: 'Test', + name: 'Test Model', + owner: 'test', + parameters: { parameters: {} }, + plans: [], + refresh_activity_type_logs: [], + refresh_model_parameter_logs: [], + refresh_resource_type_logs: [], + scheduling_specification_conditions: [], + scheduling_specification_goals: [], + version: '1.0.0', + view: null, + }, + model_id: 1, + name: 'Foo plan', + owner: 'test', + parent_plan: null, + revision: 1, + scheduling_specification: null, + simulations: [ + { + id: 3, + simulation_datasets: [ + { + id: 1, + plan_revision: 1, + }, + ], + }, + ], + start_time: '2024-01-01T00:00:00+00:00', + start_time_doy: '2024-001T00:00:00', + tags: [ + { + tag: { + color: '#fff', + created_at: '2024-01-01T00:00:00', + id: 0, + name: 'test tag', + owner: 'test', + }, + }, + ], + updated_at: '2024-01-01T00:00:00', + updated_by: 'test', + }; + + const downloadSpy = vi.fn(); + vi.spyOn(generic, 'downloadJSON').mockImplementation(downloadSpy); + + plan.exportPlan(mockPlan, mockUser, () => {}, [ + { + anchor_id: null, + anchored_to_start: true, + arguments: { + numOfTests: 1, + }, + created_at: '2024-01-01T00:00:00', + created_by: 'test', + id: 0, + last_modified_arguments_at: '2024-01-01T00:00:00', + last_modified_at: '2024-01-01T00:00:00', + metadata: {}, + name: 'Test Activity', + plan_id: 1, + source_scheduling_goal_id: null, + start_offset: '0:00:00', + start_time_ms: 0, + tags: [ + { + tag: { + color: '#ff0000', + created_at: '', + id: 1, + name: 'test tag', + owner: 'test', + }, + }, + ], + type: 'TestActivity', + }, + ]); + + expect(downloadSpy).toHaveBeenCalledOnce(); + }); }); diff --git a/src/utilities/plan.ts b/src/utilities/plan.ts index 8535c0801e..7243c89fc8 100644 --- a/src/utilities/plan.ts +++ b/src/utilities/plan.ts @@ -1,15 +1,97 @@ -import type { ActivityDirective } from '../types/activity'; +import type { ActivityDirective, ActivityDirectiveDB } from '../types/activity'; +import type { User } from '../types/app'; import type { ArgumentsMap } from '../types/parameter'; -import type { DeprecatedPlanTransfer, Plan, PlanTransfer } from '../types/plan'; +import type { DeprecatedPlanTransfer, Plan, PlanSlim, PlanTransfer } from '../types/plan'; +import type { Simulation } from '../types/simulation'; +import effects from './effects'; +import { downloadJSON } from './generic'; import { convertDoyToYmd } from './time'; -export function getPlanForTransfer( - plan: Plan, - activities: ActivityDirective[], - simulationArguments: ArgumentsMap, -): PlanTransfer { +export async function getPlanForTransfer( + plan: Plan | PlanSlim, + user: User | null, + progressCallback?: (progress: number) => void, + activities?: ActivityDirective[], + signal?: AbortSignal, +): Promise { + const simulation: Simulation | null = await effects.getPlanLatestSimulation(plan.id, user); + const qualifiedSimulationArguments: ArgumentsMap = simulation + ? { + ...simulation.template?.arguments, + ...simulation.arguments, + } + : {}; + let activitiesToQualify: ActivityDirectiveDB[] = activities ?? []; + if (activities === undefined) { + activitiesToQualify = (await effects.getActivitiesForPlan(plan.id, user)) ?? []; + } + + let totalProgress = 0; + const numOfDirectives = activitiesToQualify.length; + + const CHUNK_SIZE = 8; + const chunkedActivities = activitiesToQualify.reduce( + (prevChunks: ActivityDirectiveDB[][], activityToQualify: ActivityDirectiveDB, index) => { + const chunkIndex = Math.floor(index / CHUNK_SIZE); + if (!prevChunks[chunkIndex]) { + prevChunks[chunkIndex] = []; + } + prevChunks[chunkIndex].push(activityToQualify); + return prevChunks; + }, + [], + ); + + progressCallback?.(0); + const qualifiedActivityDirectiveChunks: ActivityDirectiveDB[][] = []; + for (let i = 0; i < chunkedActivities.length; i++) { + if (!signal?.aborted) { + const activitiesToQualifyChunk: ActivityDirectiveDB[] = chunkedActivities[i]; + qualifiedActivityDirectiveChunks[i] = await Promise.all( + activitiesToQualifyChunk.map(async activityDirective => { + if (plan) { + const effectiveArguments = await effects.getEffectiveActivityArguments( + plan?.model_id, + activityDirective.type, + activityDirective.arguments, + user, + signal, + ); + + totalProgress++; + progressCallback?.((totalProgress / numOfDirectives) * 100); + + return { + ...activityDirective, + arguments: effectiveArguments?.arguments ?? activityDirective.arguments, + }; + } + + totalProgress++; + progressCallback?.((totalProgress / numOfDirectives) * 100); + + return activityDirective; + }), + ); + } + } + + if (!signal?.aborted) { + progressCallback?.(100); + } + + const qualifiedActivityDirectives = qualifiedActivityDirectiveChunks.flat().sort((directiveA, directiveB) => { + if (directiveA.id < directiveB.id) { + return -1; + } + if (directiveA.id > directiveB.id) { + return 1; + } + return 0; + }); + return { - activities: activities.map( + activities: qualifiedActivityDirectives.map( ({ anchor_id, anchored_to_start, @@ -36,12 +118,27 @@ export function getPlanForTransfer( id: plan.id, model_id: plan.model_id, name: plan.name, - simulation_arguments: simulationArguments, + simulation_arguments: qualifiedSimulationArguments, start_time: (convertDoyToYmd(plan.start_time_doy) as string).replace('Z', '+00:00'), tags: plan.tags.map(({ tag: { color, name } }) => ({ tag: { color, name } })), + version: '2', }; } +export async function exportPlan( + plan: Plan | PlanSlim, + user: User | null, + progressCallback: (progress: number) => void, + activities?: ActivityDirective[], + signal?: AbortSignal, +): Promise { + const planTransfer = await getPlanForTransfer(plan, user, progressCallback, activities, signal); + + if (planTransfer && !signal?.aborted) { + downloadJSON(planTransfer, plan.name); + } +} + export function isDeprecatedPlanTransfer( planTransfer: PlanTransfer | DeprecatedPlanTransfer, ): planTransfer is DeprecatedPlanTransfer {