From 148989904abccc153cfb8827c2cf711cd23a83f0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Janis=20Daniel=20Da=CC=88hne?=
 <janis.daehne@informatik.uni-halle.de>
Date: Thu, 20 Mar 2025 17:12:23 +0100
Subject: [PATCH] - added function to create an exercise test with asset

---
 i18n/en.ts                                    |   6 +
 i18n/i18nRoot.ts                              |   6 +
 src/communicationLayer/exerciseEditorLayer.ts |   9 +-
 .../exerciseEditorSite/headerBarContent.tsx   | 161 +++++++++++++++++-
 src/constants.ts                              |   2 +-
 src/helpers/assetHelper.ts                    |   2 +-
 src/helpers/jsonTransformer.ts                |   2 +-
 src/types/exerciseEditor.ts                   |  15 ++
 src/types/exercisePreview.ts                  |   2 +-
 src/types/submissions.ts                      |   2 +-
 10 files changed, 195 insertions(+), 12 deletions(-)

diff --git a/i18n/en.ts b/i18n/en.ts
index 5812a318..612e236c 100644
--- a/i18n/en.ts
+++ b/i18n/en.ts
@@ -462,6 +462,12 @@ export const lang_en: LangObj = {
   "Save project": "Save project",
   "Export exercise": "Export exercise",
   "Import exercise": "Import exercise",
+  "Export tests": "Export tests",
+  "Import tests": "Import tests",
+  "Importing tests ...": "Importing tests ...",
+  "Imports tests from a json file and saves the tests to the backend": "Imports tests from a json file and saves the tests to the backend",
+  "At least one test could not be created": "At least one test could not be created",
+  "... all tests were created and saved": "... all tests were created and saved",
   "Lock exercise permanently" : "Lock exercise permanently",
   "Locked permanently" : "Locked permanently",
   "Exercise is lock": "Exercise is lock",
diff --git a/i18n/i18nRoot.ts b/i18n/i18nRoot.ts
index 527153aa..c969c35a 100644
--- a/i18n/i18nRoot.ts
+++ b/i18n/i18nRoot.ts
@@ -473,6 +473,12 @@ export interface LangObj {
   "Save project": string
   "Export exercise": string
   "Import exercise": string
+  "Export tests": string
+  "Import tests": string
+  "Importing tests ...": string
+  "Imports tests from a json file and saves the tests to the backend": string
+  "At least one test could not be created": string
+  "... all tests were created and saved": string
   "Lock exercise permanently": string
   "Locked permanently": string
   "Exercise is lock": string
diff --git a/src/communicationLayer/exerciseEditorLayer.ts b/src/communicationLayer/exerciseEditorLayer.ts
index 430c6b01..dde2c780 100644
--- a/src/communicationLayer/exerciseEditorLayer.ts
+++ b/src/communicationLayer/exerciseEditorLayer.ts
@@ -6,7 +6,7 @@ import * as genericLayer from './genericLayer'
 import {
   EditorExerciseForBackend,
   EditorExerciseFromBackend,
-  ExerciseFromBackendWithData,
+  ExerciseFromBackendWithData, ExerciseTestFromBackend, ExerciseTestFromFrontendWithData,
   FilePreviewFromBackend,
   FileWithData
 } from "../types/exerciseEditor";
@@ -165,3 +165,10 @@ export async function detachFileFromExerciseTest(exerciseId: number, testId: num
                                 `${controllerPrefixExerciseEditor}/upload/detach/tests/${exerciseId}/${testId}/${fileReferenceId}`
   )
 }
+
+
+export async function createExerciseTestWithTestAsset(exerciseId: number, test: ExerciseTestFromFrontendWithData): Promise<ExerciseTestFromBackend> {
+  return genericLayer.update<ExerciseTestFromFrontendWithData, ExerciseTestFromBackend>(callingName,
+    `${controllerPrefixExerciseEditor}/create/test/${exerciseId}`, test
+  )
+}
diff --git a/src/components/sites/exerciseEditorSite/headerBarContent.tsx b/src/components/sites/exerciseEditorSite/headerBarContent.tsx
index 43f6153b..1359fcba 100644
--- a/src/components/sites/exerciseEditorSite/headerBarContent.tsx
+++ b/src/components/sites/exerciseEditorSite/headerBarContent.tsx
@@ -15,8 +15,8 @@ import {InputOnChangeEvent} from "../../../types/reactEvents";
 import {readFileAsText} from "../../../helpers/fileReader";
 import Logger from "../../../helpers/logger";
 import {
-  EditorExerciseForBackend, EditorExerciseFromBackend, ExerciseFromBackendWithData,
-  ExerciseTestFromBackend,
+  EditorExerciseForBackend, EditorExerciseFromBackend, ExerciseFromBackendWithData, ExerciseTestForBackend,
+  ExerciseTestFromBackend, ExerciseTestFromBackendWithData, ExerciseTestFromFrontendWithData,
   TestAssetFromBackendWithData
 } from "../../../types/exerciseEditor";
 import {setEditorExercise} from "../../../state/actions/exerciseEditorSite/exerciseEditorSiteActions";
@@ -34,12 +34,18 @@ import {notifyError, notifyInfo, notifySuccess} from "../../../helpers/notificat
 import * as mousetrap from "mousetrap";
 
 
-import {getEditorExercise, getEditorExerciseWithData} from "../../../communicationLayer/exerciseEditorLayer";
+import {
+  createExerciseTestWithTestAsset,
+  getEditorExercise,
+  getEditorExerciseWithData,
+} from "../../../communicationLayer/exerciseEditorLayer";
 import {askDialog, errorDialog} from "../../../helpers/dialogHelper";
 import {mergeEditorExerciseTestAssetContents} from "../../../helpers/convertersAndTransformers";
 import {getI18n, getRawI18n} from "../../../../i18n/i18nRoot";
 import {ErrorHelper} from '../../../helpers/errorHelper'
 import {HelpPopup} from '../../helpers/helpPopup'
+import {setEditorTests} from "../../../state/actions/exerciseEditorSite/subSets/editorExerciseTestsActions";
+import {MyPopup} from "../../helpers/myPopup";
 
 declare var require: any
 const fileSaver = require('file-saver');
@@ -89,6 +95,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators({
                                                                         createEditorExerciseAsync,
                                                                         updateEditorExerciseAsync,
                                                                         lockEditorExercisePermanentlyAsync,
+                                                                        setEditorTests,
                                                                       }, dispatch)
 
 
@@ -98,6 +105,7 @@ type Props = typeof stateProps & typeof dispatchProps;
 
 
 const importJsonFileExerciseId = 'exerciseEditor-file-import'
+const importJsonFileExerciseTestsId = 'exerciseEditor-tests-file-import'
 
 class HeaderBar extends React.Component<Props, any> {
 
@@ -152,7 +160,7 @@ class HeaderBar extends React.Component<Props, any> {
     }
 
     if (this.props.isNoDialogDisplayed === false) {
-      return
+      return false
     }
 
     if (this.props.editorExercise.id <= 0) {
@@ -166,7 +174,7 @@ class HeaderBar extends React.Component<Props, any> {
 
       if (!created) {
         notifyError(getI18n(this.props.langId, 'Could not save exercise'))
-        return
+        return false
       }
 
       //because we only get the new id back (intentionally)
@@ -189,12 +197,13 @@ class HeaderBar extends React.Component<Props, any> {
 
       if (!success) {
         notifyError(getI18n(this.props.langId, 'Could not update exercise'))
-        return
+        return false
       }
 
       notifySuccess(getI18n(this.props.langId, 'Updated exercise'))
 
     }
+    return true
   }
 
 
@@ -272,6 +281,146 @@ class HeaderBar extends React.Component<Props, any> {
     </div>)
 
     const rightArea = (<div>
+
+      <div className="clickable mar-right"
+           onClick={async () => {
+
+
+             //when we create a new exercise (not copying) then the test asset contents are not loaded...
+             // noinspection TsLint
+             let exerciseToExport: ExerciseFromBackendWithData | EditorExerciseFromBackend = this.props.editorExercise
+
+             if (this.props.editorExercise.id > 0) {
+
+               notifyInfo(getI18n(this.props.langId, 'Now loading test assets (before exporting)...'))
+
+               try {
+                 //load the full exercise but only take the test asset contents
+                 const exerciseWithTestContents = await getEditorExerciseWithData(this.props.editorExercise.id)
+
+
+                 exerciseToExport = mergeEditorExerciseTestAssetContents({
+                     ...this.props.editorExercise,
+                     numReleases: 0, // set to 0 because when we import we create a new exercise... and we can only create templates if we have no releases
+                     tests: this.props.editorExercise.tests,
+                   },
+                   exerciseWithTestContents.tests
+                 )
+
+
+               } catch (err) {
+                 ErrorHelper.makeServerErrorDialog(err, this.props.langId, 'Server error', 'Could not load test assets')
+
+                 return
+               }
+             } else {
+               //when we create a new exercise the test assets are still stored at frontend (in memory)
+             }
+
+             const value = toJSONAndReplaceIds(exerciseToExport.tests)
+
+             // noinspection TsLint
+             let fileName = `${this.props.editorExercise.exerciseProperties.displayName.trim()}_tests.json`
+
+             if (!fileName) {
+               fileName = "exercise_tests.json"
+             }
+
+             const blob = new Blob([value], {type: "application/json;charset=utf-8"});
+             fileSaver.saveAs(blob, fileName)
+           }}
+      >
+        <Icon name="upload"/>
+        <span>
+          {getI18n(this.props.langId, 'Export tests')}
+        </span>
+      </div>
+
+      <div className={`${this.props.editorExercise.id <= 0 ? 'div-disabled' : 'clickable'}`}
+            onClick={() => {
+
+              const inputEl = document.getElementById(importJsonFileExerciseTestsId)
+              inputEl.click()}}>
+        <Icon name="download"/>
+        <span className="mar-right">
+            {getI18n(this.props.langId, 'Import tests')}
+        </span>
+        <input id={importJsonFileExerciseTestsId} type="file" className="collapsed" accept="application/json"
+               disabled={this.props.editorExercise.id <= 0}
+               onChange={async (e: InputOnChangeEvent) => {
+                 const files: FileList = e.currentTarget.files
+
+                 //only allowed for already saved exercises
+                 //because we need to save here and this would redirect/reload the page --> our data is gone
+                 if (this.props.editorExercise.id <= 0) {
+                   return
+                 }
+
+                 // noinspection TsLint
+                 let text: string = null
+                 try {
+                   text = await readFileAsText(files[0])
+                 } catch (err) {
+                   Logger.error(`exercise editor: ${err.message}`)
+                 }
+
+                 if (!text) {
+                   Logger.warn(`exercise editor: something went wrong reading the file`)
+                   return
+                 }
+
+                 notifyInfo(getI18n(this.props.langId, "Importing tests ..."))
+
+                 //TODO must this is insecure....
+                 //check if the parsed has every needed props
+                 // noinspection TsLint
+                 let parsed = JSON.parse(text) as ExerciseTestFromBackendWithData[]
+
+                 let mapped: ReadonlyArray<ExerciseTestFromFrontendWithData> = parsed.map(p => ({
+                   id: p.id,
+                   displayIndex: -1, //index  in array matters in setEditorTests not value here
+                   displayName: p.displayName,
+                   isSubmitTest: p.isSubmitTest,
+                   maxPoints: p.maxPoints,
+                   testTypeId: p.testTypeId,
+                   content: p.content,
+                   testSettings: {
+                     ...p.testSettings,
+                   },
+                   files: p.files.map(k => ({
+                     id: k.id,
+                     mimeType: k.mimeType,
+                     size: k.size,
+                     displayName: k.displayName,
+                     content: k.content
+                   }))
+                 }))
+
+                 /* tslint:disable-next-line */
+                 const createdTests: ExerciseTestFromBackend[] = []
+
+                 for (let i = 0; i < mapped.length; i++) {
+                   let mappedTest = mapped[i]
+                   try {
+                    const result = await createExerciseTestWithTestAsset(this.props.editorExercise.id, mappedTest)
+                    createdTests.push(result)
+                   } catch(err) {
+                     Logger.error(`exercise editor: ${err.message}`)
+                     notifyError(getI18n(this.props.langId, 'At least one test could not be created'))
+                     return
+                   }
+                 }
+
+                 this.props.setEditorTests([...this.props.editorExercise.tests, ...createdTests])
+
+                 notifySuccess(getI18n(this.props.langId, "... all tests were created and saved"))
+               }}
+        />
+      </div>
+      <HelpPopup defaultText={getI18n(this.props.langId,'Imports tests from a json file and saves the tests to the backend')}/>
+
+      <SimpleVDivider/>
+
       <div className="clickable"
            onClick={async () => {
 
diff --git a/src/constants.ts b/src/constants.ts
index 3c8c840c..f4bc357c 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -13,7 +13,7 @@ import Logger from './helpers/logger'
  * y - breaking changes / new features
  * z - fixes, small changes
  */
-export const versionString = '2.19.5'
+export const versionString = '2.20.0'
 
 
 export const supportMail = 'yapex@informatik.uni-halle.de'
diff --git a/src/helpers/assetHelper.ts b/src/helpers/assetHelper.ts
index 58f43027..353ef936 100644
--- a/src/helpers/assetHelper.ts
+++ b/src/helpers/assetHelper.ts
@@ -121,4 +121,4 @@ export function getCopyAssetUrlText(asset: FileWithData): string {
 //from https://stackoverflow.com/questions/1144783/how-to-replace-all-occurrences-of-a-string-in-javascript
 function replaceAll(value: string, search: string, replacement: string): string {
   return value.split(search).join(replacement);
-};
+}
diff --git a/src/helpers/jsonTransformer.ts b/src/helpers/jsonTransformer.ts
index 676403c4..7376ad10 100644
--- a/src/helpers/jsonTransformer.ts
+++ b/src/helpers/jsonTransformer.ts
@@ -37,7 +37,7 @@ export function toJSONAndReplaceIds(obj: any): string {
  */
  function replaceIdsTransformer(key: string, value: any): any {
   if (key === 'id') {
-    return getNextId().toString()
+    return getNextId() //.toString()
   }
 
   return value
diff --git a/src/types/exerciseEditor.ts b/src/types/exerciseEditor.ts
index 69d09be4..e2fde24f 100644
--- a/src/types/exerciseEditor.ts
+++ b/src/types/exerciseEditor.ts
@@ -115,6 +115,10 @@ export interface ExerciseFromBackendWithData extends EditorExerciseBase {
   readonly creatorId: number | null
 }
 
+export interface ExerciseTestFromFrontendWithData extends ExerciseTestBase {
+  readonly files: ReadonlyArray<TestAssetFromFrontendWithData>
+}
+
 /**
  * the properties for an exercise (in the backend these are part of the exercise but not in the frontend (have own panel)
  * --> no id
@@ -334,6 +338,17 @@ export interface ExerciseTestFromBackend extends ExerciseTestBase {
   readonly files: ReadonlyArray<FilePreviewFromBackend>
 }
 
+//almost same as TestAssetBase but with content
+export interface TestAssetFromFrontendWithData  {
+  readonly id: number
+  readonly displayName: string
+  readonly mimeType: string
+  readonly size: number
+
+  //bytes
+  readonly content: ReadonlyArray<number>
+}
+
 /**
  * a test from the frontend with asset data
  */
diff --git a/src/types/exercisePreview.ts b/src/types/exercisePreview.ts
index 612fe0b1..b6db8167 100644
--- a/src/types/exercisePreview.ts
+++ b/src/types/exercisePreview.ts
@@ -117,7 +117,7 @@ export interface ExercisePreviewFromBackend {
   readonly isReleased: boolean
 
   /**
-   * will be zero if no test was ran yet
+   * will be zero if no test has been run
    */
   readonly passedNormalTestsCount: number
   readonly maxNormalTestsCount: number
diff --git a/src/types/submissions.ts b/src/types/submissions.ts
index 2879855c..e308ddb4 100644
--- a/src/types/submissions.ts
+++ b/src/types/submissions.ts
@@ -62,7 +62,7 @@ export interface AssessmentFullBase {
    * the summed max reachable submit test points
    * this is even set for dummy assessments
    */
-  readonly maxSubmitTestPoints: number
+  readonly maxSubmitTestPoints: number | null
 
   /**
    * the summed points from the normal test
-- 
GitLab