diff --git a/jest.config.js b/jest.config.js index c4a912ecda5f..83b872ba835c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -102,7 +102,7 @@ module.exports = { // TODO: in the future, the following values should increase to at least 90% statements: 85.9, branches: 73.2, - functions: 79.8, + functions: 79.7, lines: 86.1, }, }, diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIBuildJobExecutionService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIBuildJobExecutionService.java index a2a160e4e2aa..dec7453e8f85 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIBuildJobExecutionService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIBuildJobExecutionService.java @@ -43,8 +43,8 @@ /** * This service contains the logic to execute a build job for a programming exercise participation in the local CI system. - * The {@link #runBuildJob(ProgrammingExerciseParticipation, String, String)} method is wrapped into a Callable by the {@link LocalCIBuildJobManagementService} and submitted to the - * executor service. + * The {@link #runBuildJob(ProgrammingExerciseParticipation, String, String, String)} method is wrapped into a Callable by the {@link LocalCIBuildJobManagementService} and + * submitted to the executor service. */ @Service @Profile("localci") @@ -68,8 +68,8 @@ public class LocalCIBuildJobExecutionService { @Value("${artemis.version-control.url}") private URL localVCBaseUrl; - @Value("${artemis.version-control.local-vcs-repo-path}") - private String localVCBasePath; + @Value("${artemis.repo-clone-path}") + private String repoClonePath; public LocalCIBuildJobExecutionService(LocalCIBuildPlanService localCIBuildPlanService, Optional versionControlService, LocalCIContainerService localCIContainerService, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository, XMLInputFactory localCIXMLInputFactory) { @@ -104,10 +104,11 @@ public String toString() { * @param commitHash The commit hash of the commit that should be built. If it is null, the latest commit of the default branch will be built. * @param containerName The name of the Docker container that will be used to run the build job. * It needs to be prepared beforehand to stop and remove the container if something goes wrong here. + * @param dockerImage The Docker image that will be used to run the build job. * @return The build result. * @throws LocalCIException If some error occurs while preparing or running the build job. */ - public LocalCIBuildResult runBuildJob(ProgrammingExerciseParticipation participation, String commitHash, String containerName) { + public LocalCIBuildResult runBuildJob(ProgrammingExerciseParticipation participation, String commitHash, String containerName, String dockerImage) { // Update the build plan status to "BUILDING". localCIBuildPlanService.updateBuildPlanStatus(participation, ContinuousIntegrationService.BuildStatus.BUILDING); @@ -144,7 +145,7 @@ public LocalCIBuildResult runBuildJob(ProgrammingExerciseParticipation participa for (int i = 0; i < auxiliaryRepositories.size(); i++) { auxiliaryRepositoriesUrls[i] = new LocalVCRepositoryUrl(auxiliaryRepositories.get(i).getRepositoryUrl(), localVCBaseUrl); - auxiliaryRepositoriesPaths[i] = auxiliaryRepositoriesUrls[i].getLocalRepositoryPath(localVCBasePath).toAbsolutePath(); + auxiliaryRepositoriesPaths[i] = auxiliaryRepositoriesUrls[i].getRepoClonePath(repoClonePath).toAbsolutePath(); auxiliaryRepositoryNames[i] = auxiliaryRepositories.get(i).getName(); } } @@ -157,8 +158,8 @@ public LocalCIBuildResult runBuildJob(ProgrammingExerciseParticipation participa throw new LocalCIException("Error while creating LocalVCRepositoryUrl", e); } - Path assignmentRepositoryPath = assignmentRepositoryUrl.getLocalRepositoryPath(localVCBasePath).toAbsolutePath(); - Path testsRepositoryPath = testsRepositoryUrl.getLocalRepositoryPath(localVCBasePath).toAbsolutePath(); + Path assignmentRepositoryPath = assignmentRepositoryUrl.getRepoClonePath(repoClonePath).toAbsolutePath(); + Path testsRepositoryPath = testsRepositoryUrl.getRepoClonePath(repoClonePath).toAbsolutePath(); String branch; try { @@ -171,7 +172,7 @@ public LocalCIBuildResult runBuildJob(ProgrammingExerciseParticipation participa // Create the container from the "ls1tum/artemis-maven-template" image with the local paths to the Git repositories and the shell script bound to it. Also give the // container information about the branch and commit hash to be used. // This does not start the container yet. - CreateContainerResponse container = localCIContainerService.configureContainer(containerName, branch, commitHash); + CreateContainerResponse container = localCIContainerService.configureContainer(containerName, branch, commitHash, dockerImage); return runScriptAndParseResults(participation, containerName, container.getId(), branch, commitHash, assignmentRepositoryPath, testsRepositoryPath, auxiliaryRepositoriesPaths, auxiliaryRepositoryNames, buildScriptPath); @@ -227,7 +228,7 @@ private LocalCIBuildResult runScriptAndParseResults(ProgrammingExerciseParticipa return constructFailedBuildResult(branch, assignmentRepoCommitHash, testRepoCommitHash, buildCompletedDate); } - List testResultsPaths = getTestResultPath(participation.getProgrammingExercise()); + List testResultsPaths = getTestResultPaths(participation.getProgrammingExercise()); // Get an input stream of the test result files. List testResultsTarInputStreams = new ArrayList<>(); @@ -269,34 +270,47 @@ private LocalCIBuildResult runScriptAndParseResults(ProgrammingExerciseParticipa // --- Helper methods ---- - private List getTestResultPath(ProgrammingExercise programmingExercise) { - List testResultPaths = new ArrayList<>(); + private List getTestResultPaths(ProgrammingExercise programmingExercise) { switch (programmingExercise.getProgrammingLanguage()) { case JAVA, KOTLIN -> { - if (ProjectType.isMavenProject(programmingExercise.getProjectType())) { - if (programmingExercise.hasSequentialTestRuns()) { - testResultPaths.add("/repositories/test-repository/structural/target/surefire-reports"); - testResultPaths.add("/repositories/test-repository/behavior/target/surefire-reports"); - } - else { - testResultPaths.add("/repositories/test-repository/target/surefire-reports"); - } - } - else { - if (programmingExercise.hasSequentialTestRuns()) { - testResultPaths.add("/repositories/test-repository/build/test-results/behaviorTests"); - testResultPaths.add("/repositories/test-repository/build/test-results/structuralTests"); - } - else { - testResultPaths.add("/repositories/test-repository/build/test-results/test"); - } - } - return testResultPaths; + return getJavaKotlinTestResultPaths(programmingExercise); + } + case PYTHON -> { + return getPythonTestResultPaths(); } default -> throw new IllegalArgumentException("Programming language " + programmingExercise.getProgrammingLanguage() + " is not supported"); } } + private List getJavaKotlinTestResultPaths(ProgrammingExercise programmingExercise) { + List testResultPaths = new ArrayList<>(); + if (ProjectType.isMavenProject(programmingExercise.getProjectType())) { + if (programmingExercise.hasSequentialTestRuns()) { + testResultPaths.add("/repositories/test-repository/structural/target/surefire-reports"); + testResultPaths.add("/repositories/test-repository/behavior/target/surefire-reports"); + } + else { + testResultPaths.add("/repositories/test-repository/target/surefire-reports"); + } + } + else { + if (programmingExercise.hasSequentialTestRuns()) { + testResultPaths.add("/repositories/test-repository/build/test-results/behaviorTests"); + testResultPaths.add("/repositories/test-repository/build/test-results/structuralTests"); + } + else { + testResultPaths.add("/repositories/test-repository/build/test-results/test"); + } + } + return testResultPaths; + } + + private List getPythonTestResultPaths() { + List testResultPaths = new ArrayList<>(); + testResultPaths.add("/repositories/test-repository/test-reports"); + return testResultPaths; + } + private LocalCIBuildResult parseTestResults(List testResultsTarInputStreams, String assignmentRepoBranchName, String assignmentRepoCommitHash, String testsRepoCommitHash, ZonedDateTime buildCompletedDate) throws IOException, XMLStreamException { @@ -330,7 +344,8 @@ private boolean isValidTestResultFile(TarArchiveEntry tarArchiveEntry) { int lastIndexOfSlash = name.lastIndexOf('/'); String result = (lastIndexOfSlash != -1 && lastIndexOfSlash + 1 < name.length()) ? name.substring(lastIndexOfSlash + 1) : name; - return !tarArchiveEntry.isDirectory() && result.endsWith(".xml") && result.startsWith("TEST-"); + // Java test result files are named "TEST-*.xml", Python test result files are named "*results.xml". + return !tarArchiveEntry.isDirectory() && ((result.endsWith(".xml") && result.startsWith("TEST-")) || result.endsWith("results.xml")); } private String readTarEntryContent(TarArchiveInputStream tarArchiveInputStream) throws IOException { @@ -356,6 +371,10 @@ private void processTestResultFile(String testResultFileString, List addBuildJobToQueue(ProgrammingExerc ProgrammingExercise programmingExercise = participation.getProgrammingExercise(); - List supportedProjectTypes = localCIProgrammingLanguageFeatureService.getProgrammingLanguageFeatures(programmingExercise.getProgrammingLanguage()) - .projectTypes(); + ProgrammingLanguage programmingLanguage = programmingExercise.getProgrammingLanguage(); + + ProjectType projectType = programmingExercise.getProjectType(); + + String dockerImage = programmingLanguageConfiguration.getImage(programmingLanguage, Optional.ofNullable(projectType)); - var projectType = programmingExercise.getProjectType(); + List supportedProjectTypes = localCIProgrammingLanguageFeatureService.getProgrammingLanguageFeatures(programmingLanguage).projectTypes(); if (projectType != null && !supportedProjectTypes.contains(programmingExercise.getProjectType())) { throw new LocalCIException("The project type " + programmingExercise.getProjectType() + " is not supported by the local CI."); @@ -95,7 +102,7 @@ public CompletableFuture addBuildJobToQueue(ProgrammingExerc String containerName = "artemis-local-ci-" + participation.getId() + "-" + ZonedDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS")); // Prepare a Callable that will later be called. It contains the actual steps needed to execute the build job. - Callable buildJob = () -> localCIBuildJobExecutionService.runBuildJob(participation, commitHash, containerName); + Callable buildJob = () -> localCIBuildJobExecutionService.runBuildJob(participation, commitHash, containerName, dockerImage); // Wrap the buildJob Callable in a BuildJobTimeoutCallable, so that the build job is cancelled if it takes too long. BuildJobTimeoutCallable timedBuildJob = new BuildJobTimeoutCallable<>(buildJob, timeoutSeconds); diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java index 0732e58357ec..e83d5387126d 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIContainerService.java @@ -66,10 +66,11 @@ public LocalCIContainerService(DockerClient dockerClient) { * @param containerName the name of the container to be created * @param branch the branch to checkout * @param commitHash the commit hash to checkout. If it is null, the latest commit of the branch will be checked out. + * @param image the Docker image to use for the container * @return {@link CreateContainerResponse} that can be used to start the container */ - public CreateContainerResponse configureContainer(String containerName, String branch, String commitHash) { - return dockerClient.createContainerCmd(dockerImage).withName(containerName).withHostConfig(HostConfig.newHostConfig().withAutoRemove(true)) + public CreateContainerResponse configureContainer(String containerName, String branch, String commitHash, String image) { + return dockerClient.createContainerCmd(image).withName(containerName).withHostConfig(HostConfig.newHostConfig().withAutoRemove(true)) .withEnv("ARTEMIS_BUILD_TOOL=gradle", "ARTEMIS_DEFAULT_BRANCH=" + branch, "ARTEMIS_ASSIGNMENT_REPOSITORY_COMMIT_HASH=" + (commitHash != null ? commitHash : "")) // Command to run when the container starts. This is the command that will be executed in the container's main process, which runs in the foreground and blocks the // container from exiting until it finishes. @@ -307,45 +308,64 @@ public Path createBuildScript(ProgrammingExercise programmingExercise, List scriptForJavaKotlin(programmingExercise, buildScript, hasSequentialTestRuns); + case PYTHON -> scriptForPython(buildScript); default -> throw new IllegalArgumentException("No build stage setup for programming language " + programmingExercise.getProgrammingLanguage()); } @@ -359,6 +379,23 @@ public Path createBuildScript(ProgrammingExercise programmingExercise, List auxiliaryRepositories) { + StringBuilder buildScript = new StringBuilder(); + for (AuxiliaryRepository auxiliaryRepository : auxiliaryRepositories) { + buildScript.append(" git clone --depth 1 --branch $ARTEMIS_DEFAULT_BRANCH file:///").append(auxiliaryRepository.getName()).append("-repository\n"); + } + return buildScript; + } + + private StringBuilder copyAuxiliaryRepositories(List auxiliaryRepositories, String source) { + StringBuilder buildScript = new StringBuilder(); + for (AuxiliaryRepository auxiliaryRepository : auxiliaryRepositories) { + buildScript.append(" cp -a ").append(source).append(auxiliaryRepository.getName()).append("-repository/. /repositories/test-repository/") + .append(auxiliaryRepository.getCheckoutDirectory()).append("/\n"); + } + return buildScript; + } + private void scriptForJavaKotlin(ProgrammingExercise programmingExercise, StringBuilder buildScript, boolean hasSequentialTestRuns) { boolean isMaven = ProjectType.isMavenProject(programmingExercise.getProjectType()); @@ -398,6 +435,18 @@ private void scriptForJavaKotlin(ProgrammingExercise programmingExercise, String } } + private void scriptForPython(StringBuilder buildScript) { + buildScript.append(""" + python3 -m compileall . -q || error=true + if [ ! $error ] + then + pytest --junitxml=test-reports/results.xml + else + exit 1 + fi + """); + } + /** * Deletes the build script for a given programming exercise. * The build script is stored in a file in the local-ci-scripts directory. diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java index 3be1c6fca9c8..30603c6ff307 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIProgrammingLanguageFeatureService.java @@ -23,7 +23,7 @@ public LocalCIProgrammingLanguageFeatureService() { // TODO LOCALVC_CI: Local CI is not supporting EMPTY at the moment. programmingLanguageFeatures.put(JAVA, new ProgrammingLanguageFeature(JAVA, true, false, true, true, false, List.of(PLAIN_GRADLE, GRADLE_GRADLE, PLAIN_MAVEN, MAVEN_MAVEN), false, false, true)); - // TODO LOCALVC_CI: Local CI is not supporting Python at the moment. + programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, false, true, false, false, List.of(), false, false, true)); // TODO LOCALVC_CI: Local CI is not supporting C at the moment. // TODO LOCALVC_CI: Local CI is not supporting Haskell at the moment. // TODO LOCALVC_CI: Local CI is not supporting Kotlin at the moment. diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIService.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIService.java index e8306d789aa7..ee4f0e261052 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIService.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localci/LocalCIService.java @@ -5,12 +5,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import de.tum.in.www1.artemis.config.ProgrammingLanguageConfiguration; import de.tum.in.www1.artemis.domain.ProgrammingExercise; import de.tum.in.www1.artemis.domain.VcsRepositoryUrl; import de.tum.in.www1.artemis.domain.participation.ProgrammingExerciseParticipation; @@ -38,20 +38,22 @@ public class LocalCIService extends AbstractContinuousIntegrationService { private final LocalCIDockerService localCIDockerService; - @Value("${artemis.continuous-integration.build.images.java.default}") - String dockerImage; + private final ProgrammingLanguageConfiguration programmingLanguageConfiguration; public LocalCIService(ProgrammingSubmissionRepository programmingSubmissionRepository, FeedbackRepository feedbackRepository, BuildLogEntryService buildLogService, - BuildLogStatisticsEntryRepository buildLogStatisticsEntryRepository, TestwiseCoverageService testwiseCoverageService, LocalCIDockerService localCIDockerService) { + BuildLogStatisticsEntryRepository buildLogStatisticsEntryRepository, TestwiseCoverageService testwiseCoverageService, LocalCIDockerService localCIDockerService, + ProgrammingLanguageConfiguration programmingLanguageConfiguration) { super(programmingSubmissionRepository, feedbackRepository, buildLogService, buildLogStatisticsEntryRepository, testwiseCoverageService); this.localCIDockerService = localCIDockerService; + this.programmingLanguageConfiguration = programmingLanguageConfiguration; } @Override public void createBuildPlanForExercise(ProgrammingExercise programmingExercise, String planKey, VcsRepositoryUrl sourceCodeRepositoryURL, VcsRepositoryUrl testRepositoryURL, VcsRepositoryUrl solutionRepositoryURL) { // Only check whether the docker image needed for the build plan exists. - localCIDockerService.pullDockerImage(dockerImage); + localCIDockerService.pullDockerImage( + programmingLanguageConfiguration.getImage(programmingExercise.getProgrammingLanguage(), Optional.ofNullable(programmingExercise.getProjectType()))); } @Override diff --git a/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCRepositoryUrl.java b/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCRepositoryUrl.java index d9a3067f46c6..68cacd5648a0 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCRepositoryUrl.java +++ b/src/main/java/de/tum/in/www1/artemis/service/connectors/localvc/LocalVCRepositoryUrl.java @@ -151,4 +151,14 @@ public boolean isPracticeRepository() { public Path getLocalRepositoryPath(String localVCBasePath) { return Paths.get(localVCBasePath, projectKey, repositorySlug + ".git"); } + + /** + * Get the path to the cloned repository + * + * @param baseRepoClonePath the base path of the cloned repositories + * @return the path to the cloned repository + */ + public Path getRepoClonePath(String baseRepoClonePath) { + return Paths.get(baseRepoClonePath, "git", projectKey, repositorySlug); + } } diff --git a/src/main/webapp/app/core/theme/theme.service.ts b/src/main/webapp/app/core/theme/theme.service.ts index 660877215912..a6fa87c0c01a 100644 --- a/src/main/webapp/app/core/theme/theme.service.ts +++ b/src/main/webapp/app/core/theme/theme.service.ts @@ -149,19 +149,28 @@ export class ThemeService { /** * Prints the current page. * Disables any theme override before doing that to ensure that we print in default theme. - * Resets the theme afterwards if needed + * Resets the theme afterward if needed */ - public print() { - const overrideTag: any = document.getElementById(THEME_OVERRIDE_ID); - if (overrideTag) { - overrideTag.rel = 'none-tmp'; - } - setTimeout(() => window.print(), 250); - setTimeout(() => { + public async print(): Promise { + return new Promise((resolve) => { + const overrideTag: any = document.getElementById(THEME_OVERRIDE_ID); if (overrideTag) { - overrideTag.rel = 'stylesheet'; + overrideTag.rel = 'none-tmp'; } - }, 500); + setTimeout(() => { + const notificationSidebarDisplayAttribute = this.hideNotificationSidebar(); + + window.print(); + + this.showNotificationSidebar(notificationSidebarDisplayAttribute); + }, 250); + setTimeout(() => { + if (overrideTag) { + overrideTag.rel = 'stylesheet'; + } + resolve(); + }, 500); + }); } /** @@ -235,4 +244,39 @@ export class ThemeService { this.preferenceSubject.next(theme); } } + + /** + * Hides the notification sidebar as there will be an overlay ove the whole page + * that covers details of the exam summary (=> exam summary cannot be read). + * + * @return displayAttribute of the notification sidebar before hiding it + */ + private hideNotificationSidebar(): string { + return this.modifyNotificationSidebarDisplayStyling(); + } + + /** + * After printing the notification sidebar shall be displayed again. + * + * @param displayAttributeBeforeHide to reset the notification sidebar to its previous state + * @return displayAttribute of the notification sidebar before hiding it + */ + private showNotificationSidebar(displayAttributeBeforeHide: string): string { + return this.modifyNotificationSidebarDisplayStyling(displayAttributeBeforeHide); + } + + /** + * @param newDisplayAttribute that is set for the {@link NotificationSidebarComponent} + * @return displayAttribute of the notification sidebar before hiding it + */ + private modifyNotificationSidebarDisplayStyling(newDisplayAttribute?: string): string { + const notificationSidebarElement: any = document.getElementById('notification-sidebar'); + let displayBefore = ''; + + if (notificationSidebarElement) { + displayBefore = notificationSidebarElement.style.display; + notificationSidebarElement.style.display = newDisplayAttribute !== undefined ? newDisplayAttribute : 'none'; + } + return displayBefore; + } } diff --git a/src/main/webapp/app/course/plagiarism-cases/instructor-view/detail-view/plagiarism-case-instructor-detail-view.component.ts b/src/main/webapp/app/course/plagiarism-cases/instructor-view/detail-view/plagiarism-case-instructor-detail-view.component.ts index b700b643d025..e3f890c4be1f 100644 --- a/src/main/webapp/app/course/plagiarism-cases/instructor-view/detail-view/plagiarism-case-instructor-detail-view.component.ts +++ b/src/main/webapp/app/course/plagiarism-cases/instructor-view/detail-view/plagiarism-case-instructor-detail-view.component.ts @@ -218,7 +218,7 @@ export class PlagiarismCaseInstructorDetailViewComponent implements OnInit, OnDe /** * Prints the whole page using the theme service */ - printPlagiarismCase(): void { - this.themeService.print(); + async printPlagiarismCase() { + return await this.themeService.print(); } } diff --git a/src/main/webapp/app/exam/participate/exam-participation.component.html b/src/main/webapp/app/exam/participate/exam-participation.component.html index 33ba5e62e625..1751278706b6 100644 --- a/src/main/webapp/app/exam/participate/exam-participation.component.html +++ b/src/main/webapp/app/exam/participate/exam-participation.component.html @@ -146,7 +146,7 @@

Show Summary - + diff --git a/src/main/webapp/app/exam/participate/general-information/exam-general-information.component.ts b/src/main/webapp/app/exam/participate/general-information/exam-general-information.component.ts index 810549d0b949..fb79c9e8ffab 100644 --- a/src/main/webapp/app/exam/participate/general-information/exam-general-information.component.ts +++ b/src/main/webapp/app/exam/participate/general-information/exam-general-information.component.ts @@ -14,8 +14,10 @@ export class ExamGeneralInformationComponent implements OnChanges { @Input() studentExam: StudentExam; @Input() reviewIsOpen?: boolean = false; - /** The exam cover will contain e.g. the number of exercises which is hidden in the exam summary as - * the information is shown in the {@link ExamResultOverviewComponent} */ + /** + * The exam cover will contain e.g. the number of exercises which is hidden in the exam summary as + * the information is shown in the {@link ExamResultOverviewComponent} + */ @Input() displayOnExamCover?: boolean = false; examEndDate?: dayjs.Dayjs; diff --git a/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.html b/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.html index 6074dac2fbdb..b400dd5a7d72 100644 --- a/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.html +++ b/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.html @@ -13,51 +13,31 @@

*ngIf="studentExam?.exam" [exam]="studentExam.exam!" [studentExam]="studentExam" - [reviewIsOpen]="studentExam?.exam && isBeforeStudentReviewEnd() && isAfterStudentReviewStart() && !isTestRun" + [reviewIsOpen]="studentExam?.exam && isBeforeStudentReviewEnd && isAfterStudentReviewStart && !isTestRun" /> -
+
-
+
{{ 'artemisApp.exam.resultInformation' | artemisTranslate }}
+ +

+ {{ 'artemisApp.exam.exercises' | artemisTranslate }} +

-
-
-    - Exercise {{ i + 1 }} -
-
- Warning: You are viewing an illegal submission. -
-
- -
- -
-
+ + +
- 'artemisApp.plagiarism.plagiarismCases.' + (plagiarismCaseInfos[exercise.id!]!.verdict === PlagiarismVerdict.NO_PLAGIARISM ? 'resolvedPlagiarismCase' : 'plagiarismCase') " - >Plagiarism Case + Plagiarism Case + - - View Example Solution - + You are viewing the example solution. + + + + [displayedOnExamSummary]="true" + /> - -
{{ exercise?.exerciseGroup?.title ?? '-' }} - [{{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}, {{ exercise.bonusPoints }} {{ 'artemisApp.examParticipation.bonus' | artemisTranslate }}] - -
-
- -
- - + +
+ +
+ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - + - - + + [participation]="exerciseInfos[exercise.id!]!.participation!" + [submission]="exerciseInfos[exercise.id!]!.submission!" + [isTestRun]="isTestRun" + [exam]="examWithOnlyIdAndStudentReviewPeriod" + [isAfterStudentReviewStart]="isAfterStudentReviewStart" + [resultsPublished]="resultsArePublished" + [isPrinting]="isPrinting" + [isAfterResultsArePublished]="resultsArePublished" + /> - - {{ 'artemisApp.exam.examSummary.noSubmissionFound' | artemisTranslate }} @@ -174,3 +163,8 @@
+ +   {{ 'artemisApp.exam.examSummary.backToOverview' | artemisTranslate }}  + diff --git a/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.ts b/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.ts index b8d9b2a2826c..95184a3aca69 100644 --- a/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.ts +++ b/src/main/webapp/app/exam/participate/summary/exam-result-summary.component.ts @@ -6,15 +6,41 @@ import { ActivatedRoute } from '@angular/router'; import { ArtemisServerDateService } from 'app/shared/server-date.service'; import { Exam } from 'app/entities/exam.model'; import { AssessmentType } from 'app/entities/assessment-type.model'; -import { SubmissionType } from 'app/entities/submission.model'; -import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; -import { faAngleDown, faAngleRight, faFolderOpen, faInfoCircle, faPrint } from '@fortawesome/free-solid-svg-icons'; import { ThemeService } from 'app/core/theme/theme.service'; -import { StudentExamWithGradeDTO } from 'app/exam/exam-scores/exam-score-dtos.model'; +import { ExerciseResult, StudentExamWithGradeDTO } from 'app/exam/exam-scores/exam-score-dtos.model'; import { ExamParticipationService } from 'app/exam/participate/exam-participation.service'; import { PlagiarismCasesService } from 'app/course/plagiarism-cases/shared/plagiarism-cases.service'; import { PlagiarismCaseInfo } from 'app/exercises/shared/plagiarism/types/PlagiarismCaseInfo'; import { PlagiarismVerdict } from 'app/exercises/shared/plagiarism/types/PlagiarismVerdict'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { roundScorePercentSpecifiedByCourseSettings } from 'app/shared/util/utils'; +import { getLatestResultOfStudentParticipation } from 'app/exercises/shared/participation/participation.utils'; +import { evaluateTemplateStatus, getResultIconClass, getTextColorClass } from 'app/exercises/shared/result/result.utils'; +import { Submission } from 'app/entities/submission.model'; +import { Participation } from 'app/entities/participation/participation.model'; +import { faArrowUp, faEye, faEyeSlash, faFolderOpen, faInfoCircle, faPrint } from '@fortawesome/free-solid-svg-icons'; +import { cloneDeep } from 'lodash-es'; +import { captureException } from '@sentry/angular-ivy'; +import { AlertService } from 'app/core/util/alert.service'; + +export type ResultSummaryExerciseInfo = { + icon: IconProp; + isCollapsed: boolean; + achievedPoints?: number; + achievedPercentage?: number; + colorClass?: string; + resultIconClass?: IconProp; + + submission?: Submission; + participation?: Participation; + displayExampleSolution: boolean; +}; + +type StateBeforeResetting = { + exerciseInfos: Record; + isGradingKeyCollapsed: boolean; + isBonusGradingKeyCollapsed: boolean; +}; @Component({ selector: 'jhi-exam-participation-summary', @@ -30,13 +56,20 @@ export class ExamResultSummaryComponent implements OnInit { readonly FILE_UPLOAD = ExerciseType.FILE_UPLOAD; readonly AssessmentType = AssessmentType; readonly IncludedInOverallScore = IncludedInOverallScore; - readonly SUBMISSION_TYPE_ILLEGAL = SubmissionType.ILLEGAL; readonly PlagiarismVerdict = PlagiarismVerdict; + faFolderOpen = faFolderOpen; + faInfoCircle = faInfoCircle; + faPrint = faPrint; + faEye = faEye; + faEyeSlash = faEyeSlash; + faArrowUp = faArrowUp; + /** * Current student's exam. */ private _studentExam: StudentExam; + plagiarismCaseInfos: { [exerciseId: number]: PlagiarismCaseInfo } = {}; exampleSolutionPublished = false; @@ -64,8 +97,6 @@ export class ExamResultSummaryComponent implements OnInit { @Input() instructorView = false; - collapsedExerciseIds: number[] = []; - courseId: number; isTestRun = false; @@ -76,12 +107,26 @@ export class ExamResultSummaryComponent implements OnInit { examWithOnlyIdAndStudentReviewPeriod: Exam; - // Icons - faFolderOpen = faFolderOpen; - faInfoCircle = faInfoCircle; - faPrint = faPrint; - faAngleRight = faAngleRight; - faAngleDown = faAngleDown; + isBeforeStudentReviewEnd = false; + /** + * Used to decide whether to instantiate the ComplaintInteractionComponent. We always instantiate the component if + * the review dates are set and the review start date has passed. + */ + isAfterStudentReviewStart = false; + + exerciseInfos: Record; + + /** + * Passed to components with overlapping elements to ensure that the overlapping + * elements are displayed in a different order for printing. + */ + isPrinting = false; + + /** + * Passed to components where the problem statement might be expanded or collapsed to ensure that + * the problem statement is expanded while printing + */ + expandProblemStatement = false; constructor( private route: ActivatedRoute, @@ -89,6 +134,7 @@ export class ExamResultSummaryComponent implements OnInit { private themeService: ThemeService, private examParticipationService: ExamParticipationService, private plagiarismCasesService: PlagiarismCasesService, + private alertService: AlertService, ) {} /** @@ -114,12 +160,34 @@ export class ExamResultSummaryComponent implements OnInit { .subscribe((studentExamWithGrade: StudentExamWithGradeDTO) => { studentExamWithGrade.studentExam = this.studentExam; this.studentExamGradeInfoDTO = studentExamWithGrade; + this.exerciseInfos = this.getExerciseInfos(studentExamWithGrade); }); } this.exampleSolutionPublished = !!this.studentExam.exam?.exampleSolutionPublicationDate && dayjs().isAfter(this.studentExam.exam.exampleSolutionPublicationDate); + this.exerciseInfos = this.getExerciseInfos(); + this.setExamWithOnlyIdAndStudentReviewPeriod(); + + this.isBeforeStudentReviewEnd = this.getIsBeforeStudentReviewEnd(); + this.isAfterStudentReviewStart = this.getIsAfterStudentReviewStart(); + } + + get resultsArePublished(): boolean | any { + if (this.isTestRun || this.isTestExam) { + return true; + } + + if (this.testRunConduction || this.testExamConduction) { + return false; + } + + if (this.studentExam?.exam?.publishResultsDate) { + return dayjs(this.studentExam.exam.publishResultsDate).isBefore(dayjs()); + } + + return false; } private tryLoadPlagiarismCaseInfosForStudent() { @@ -145,31 +213,67 @@ export class ExamResultSummaryComponent implements OnInit { return exam?.publishResultsDate && dayjs(exam.publishResultsDate).isBefore(this.serverDateService.now()); } - asProgrammingExercise(exercise: Exercise): ProgrammingExercise { - return exercise as ProgrammingExercise; + /** + * called for exportPDF Button + */ + async printPDF() { + const stateBeforeResetting = this.expandExercisesAndGradingKeysBeforePrinting(); + + this.isPrinting = true; + this.expandProblemStatement = true; + + await this.themeService.print(); + + this.isPrinting = false; + this.expandProblemStatement = false; + + this.resetExpandingExercisesAndGradingKeys(stateBeforeResetting); } - get resultsPublished() { - if (this.testRunConduction || this.testExamConduction) { - return false; - } else if (this.isTestRun || this.isTestExam) { - return true; - } - return this.studentExam?.exam?.publishResultsDate && dayjs(this.studentExam.exam.publishResultsDate).isBefore(dayjs()); + private scrollToTop() { + window.scrollTo(0, 0); } - /** - * called for exportPDF Button - */ - printPDF() { - this.expandExercisesAndGradingKeysBeforePrinting(); - setTimeout(() => this.themeService.print()); + scrollToOverviewOrTop() { + const searchedId = 'exam-summary-result-overview'; + const targetElement = document.getElementById(searchedId); + + if (targetElement) { + targetElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest', + }); + } else { + this.scrollToTop(); + } } private expandExercisesAndGradingKeysBeforePrinting() { - this.collapsedExerciseIds = []; + const stateBeforeResetting = { + exerciseInfos: cloneDeep(this.exerciseInfos), + isGradingKeyCollapsed: cloneDeep(this.isGradingKeyCollapsed), + isBonusGradingKeyCollapsed: cloneDeep(this.isBonusGradingKeyCollapsed), + }; + + this.expandExercises(); + this.isGradingKeyCollapsed = false; this.isBonusGradingKeyCollapsed = false; + + return stateBeforeResetting; + } + + private resetExpandingExercisesAndGradingKeys(stateBeforeResetting: StateBeforeResetting) { + this.exerciseInfos = stateBeforeResetting.exerciseInfos; + this.isGradingKeyCollapsed = stateBeforeResetting.isGradingKeyCollapsed; + this.isBonusGradingKeyCollapsed = stateBeforeResetting.isBonusGradingKeyCollapsed; + } + + private expandExercises() { + Object.entries(this.exerciseInfos).forEach((exerciseInfo: [string, ResultSummaryExerciseInfo]) => { + exerciseInfo[1].isCollapsed = false; + }); } public generateLink(exercise: Exercise) { @@ -195,28 +299,6 @@ export class ExamResultSummaryComponent implements OnInit { return exercise.studentParticipations?.[0] || undefined; } - /** - * @param exerciseId - * checks collapse control of exercise cards depending on exerciseId - */ - isCollapsed(exerciseId: number): boolean { - return this.collapsedExerciseIds.includes(exerciseId); - } - - /** - * @param exerciseId - * adds collapse control of exercise cards depending on exerciseId - * @param exerciseId the exercise for which the submission should be collapsed - */ - toggleCollapseExercise(exerciseId: number): void { - const collapsed = this.isCollapsed(exerciseId); - if (collapsed) { - this.collapsedExerciseIds = this.collapsedExerciseIds.filter((id) => id !== exerciseId); - } else { - this.collapsedExerciseIds.push(exerciseId); - } - } - /** * We only need to pass these values to the ComplaintInteractionComponent */ @@ -229,11 +311,7 @@ export class ExamResultSummaryComponent implements OnInit { this.examWithOnlyIdAndStudentReviewPeriod = exam; } - /** - * Used to decide whether to instantiate the ComplaintInteractionComponent. We always instantiate the component if - * the review dates are set and the review start date has passed. - */ - isAfterStudentReviewStart() { + private getIsAfterStudentReviewStart() { if (this.isTestRun || this.isTestExam) { return true; } @@ -243,7 +321,7 @@ export class ExamResultSummaryComponent implements OnInit { return false; } - isBeforeStudentReviewEnd() { + private getIsBeforeStudentReviewEnd() { if (this.isTestRun || this.isTestExam) { return true; } @@ -253,5 +331,118 @@ export class ExamResultSummaryComponent implements OnInit { return false; } + private getExerciseInfos(studentExamWithGrade?: StudentExamWithGradeDTO): Record { + const exerciseInfos: Record = {}; + for (const exercise of this.studentExam?.exercises ?? []) { + if (exercise.id === undefined) { + this.alertService.error('artemisApp.exam.error.cannotDisplayExerciseDetails', { exerciseGroupTitle: exercise.exerciseGroup?.title }); + const errorMessage = 'Cannot getExerciseInfos as exerciseId is undefined'; + captureException(new Error(errorMessage), { + extra: { + exercise, + }, + }); + continue; + } + + const { textColorClass, resultIconClass } = this.getTextColorAndIconClassByExercise(exercise); + + exerciseInfos[exercise.id] = { + icon: getIcon(exercise.type), + isCollapsed: false, + achievedPoints: this.getPointsByExerciseIdFromExam(exercise.id, studentExamWithGrade), + achievedPercentage: this.getAchievedPercentageByExerciseId(exercise.id), + colorClass: textColorClass, + resultIconClass: resultIconClass, + + submission: this.getSubmissionForExercise(exercise), + participation: this.getParticipationForExercise(exercise), + displayExampleSolution: false, + }; + } + return exerciseInfos; + } + + private getPointsByExerciseIdFromExam(exerciseId: number, studentExamWithGrade?: StudentExamWithGradeDTO): number | undefined { + if (!studentExamWithGrade) { + return undefined; + } + + for (const achievedPointsPerExerciseKey in this.studentExamGradeInfoDTO?.achievedPointsPerExercise) { + if (Number(achievedPointsPerExerciseKey) === exerciseId) { + return this.studentExamGradeInfoDTO.achievedPointsPerExercise[achievedPointsPerExerciseKey]; + } + } + + return undefined; + } + + private getExerciseResultByExerciseId(exerciseId?: number): ExerciseResult | undefined { + if (exerciseId === undefined) { + return undefined; + } + + const exerciseGroupResultMapping = this.studentExamGradeInfoDTO?.studentResult?.exerciseGroupIdToExerciseResult; + let exerciseResult = undefined; + + for (const key in exerciseGroupResultMapping) { + if (key in exerciseGroupResultMapping && exerciseGroupResultMapping[key].exerciseId === exerciseId) { + exerciseResult = exerciseGroupResultMapping[key]; + break; + } + } + + return exerciseResult; + } + + toggleShowSampleSolution(exerciseId?: number) { + if (exerciseId === undefined) { + this.alertService.error('artemisApp.exam.error.cannotShowExampleSolution'); + const errorMessage = 'Cannot show sample solution because exercise id is undefined'; + captureException(new Error(errorMessage), { + extra: { + exerciseId, + }, + }); + + return; + } + + this.exerciseInfos[exerciseId].displayExampleSolution = !this.exerciseInfos[exerciseId].displayExampleSolution; + } + + getAchievedPercentageByExerciseId(exerciseId?: number): number | undefined { + const result = this.getExerciseResultByExerciseId(exerciseId); + if (result === undefined) { + return undefined; + } + + const course = this.studentExamGradeInfoDTO.studentExam?.exam?.course; + if (result.achievedScore !== undefined) { + return roundScorePercentSpecifiedByCourseSettings(result.achievedScore / 100, course); + } + + const canCalculatePercentage = result.maxScore && result.achievedPoints !== undefined; + if (canCalculatePercentage) { + return roundScorePercentSpecifiedByCourseSettings(result.achievedPoints! / result.maxScore, course); + } + + return undefined; + } + + getTextColorAndIconClassByExercise(exercise: Exercise) { + const participation = exercise.studentParticipations![0]; + const showUngradedResults = false; + const result = getLatestResultOfStudentParticipation(participation, showUngradedResults); + + const isBuilding = false; + const templateStatus = evaluateTemplateStatus(exercise, participation, result, isBuilding); + + return { + textColorClass: getTextColorClass(result, templateStatus), + resultIconClass: getResultIconClass(result, templateStatus), + }; + } + protected readonly getIcon = getIcon; } diff --git a/src/main/webapp/app/exam/participate/summary/exam-result-summary.module.ts b/src/main/webapp/app/exam/participate/summary/exam-result-summary.module.ts index 9cf61c27e9df..4e3016fbc298 100644 --- a/src/main/webapp/app/exam/participate/summary/exam-result-summary.module.ts +++ b/src/main/webapp/app/exam/participate/summary/exam-result-summary.module.ts @@ -26,6 +26,11 @@ import { ExampleSolutionComponent } from 'app/exercises/shared/example-solution/ import { ArtemisProgrammingExerciseManagementModule } from 'app/exercises/programming/manage/programming-exercise-management.module'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { GradingKeyOverviewModule } from 'app/grading-system/grading-key-overview/grading-key-overview.module'; +import { ExamResultSummaryExerciseCardHeaderComponent } from 'app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component'; +import { ArtemisModelingParticipationModule } from 'app/exercises/modeling/participate/modeling-participation.module'; +import { ArtemisTextParticipationModule } from 'app/exercises/text/participate/text-participation.module'; +import { ArtemisFileUploadParticipationModule } from 'app/exercises/file-upload/participate/file-upload-participation.module'; +import { ArtemisFeedbackModule } from 'app/exercises/shared/feedback/feedback.module'; @NgModule({ imports: [ @@ -47,6 +52,10 @@ import { GradingKeyOverviewModule } from 'app/grading-system/grading-key-overvie ArtemisExamSharedModule, ArtemisSharedComponentModule, GradingKeyOverviewModule, + ArtemisModelingParticipationModule, + ArtemisTextParticipationModule, + ArtemisFileUploadParticipationModule, + ArtemisFeedbackModule, ], declarations: [ ExamResultSummaryComponent, @@ -57,6 +66,7 @@ import { GradingKeyOverviewModule } from 'app/grading-system/grading-key-overvie QuizExamSummaryComponent, ExamGeneralInformationComponent, ExamResultOverviewComponent, + ExamResultSummaryExerciseCardHeaderComponent, TestRunRibbonComponent, ExampleSolutionComponent, ], diff --git a/src/main/webapp/app/exam/participate/summary/exercises/file-upload-exam-summary/file-upload-exam-summary.component.html b/src/main/webapp/app/exam/participate/summary/exercises/file-upload-exam-summary/file-upload-exam-summary.component.html index 126748a64765..3969bdb473e5 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/file-upload-exam-summary/file-upload-exam-summary.component.html +++ b/src/main/webapp/app/exam/participate/summary/exercises/file-upload-exam-summary/file-upload-exam-summary.component.html @@ -1,9 +1,15 @@ -
- {{ 'artemisApp.fileUploadAssessment.submissionFile' | artemisTranslate }} - - {{ attachmentExtension(submission.filePath!) | uppercase }} - +
+ + +
No submission
+
- -
No submission
-
diff --git a/src/main/webapp/app/exam/participate/summary/exercises/file-upload-exam-summary/file-upload-exam-summary.component.ts b/src/main/webapp/app/exam/participate/summary/exercises/file-upload-exam-summary/file-upload-exam-summary.component.ts index a8e1b717e56d..0137462b144c 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/file-upload-exam-summary/file-upload-exam-summary.component.ts +++ b/src/main/webapp/app/exam/participate/summary/exercises/file-upload-exam-summary/file-upload-exam-summary.component.ts @@ -1,34 +1,17 @@ import { Component, Input } from '@angular/core'; import { FileUploadSubmission } from 'app/entities/file-upload-submission.model'; -import { FileService } from 'app/shared/http/file.service'; +import { Exercise } from 'app/entities/exercise.model'; @Component({ selector: 'jhi-file-upload-exam-summary', templateUrl: './file-upload-exam-summary.component.html', }) export class FileUploadExamSummaryComponent { - @Input() - submission: FileUploadSubmission; + @Input() submission: FileUploadSubmission; - constructor(private fileService: FileService) {} + @Input() exercise: Exercise; - /** - * - * @param filePath - * File Upload Exercise - */ - downloadFile(filePath: string | undefined) { - if (!filePath) { - return; - } - this.fileService.downloadFile(filePath); - } + @Input() expandProblemStatement?: boolean = false; - attachmentExtension(filePath: string): string { - if (!filePath) { - return 'N/A'; - } - - return filePath.split('.').pop()!; - } + @Input() isAfterResultsArePublished?: boolean = false; } diff --git a/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.html b/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.html new file mode 100644 index 000000000000..0e09280ef5ef --- /dev/null +++ b/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.html @@ -0,0 +1,34 @@ +
+
+
+ #{{ index + 1 }}    + {{ exercise.exerciseGroup?.title }} +
+ +
+
+ [{{ exerciseInfo?.achievedPoints }} / {{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}] + +
+ + {{ exerciseInfo!.achievedPercentage }}% +
+
+ [{{ exercise.maxPoints }} {{ 'artemisApp.examParticipation.points' | artemisTranslate }}] +
+
+ +
+ Warning: You are viewing an illegal submission. +
+ +
diff --git a/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.scss b/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.scss new file mode 100644 index 000000000000..8439109a11dc --- /dev/null +++ b/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.scss @@ -0,0 +1,7 @@ +.rotate-icon { + transition: transform 0.3s ease; +} + +.rotate-icon.rotated { + transform: rotate(90deg); +} diff --git a/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.ts b/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.ts new file mode 100644 index 000000000000..bca57b16e669 --- /dev/null +++ b/src/main/webapp/app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.ts @@ -0,0 +1,25 @@ +import { Component, Input } from '@angular/core'; +import { Exercise } from 'app/entities/exercise.model'; +import { faAngleRight } from '@fortawesome/free-solid-svg-icons'; +import { ResultSummaryExerciseInfo } from 'app/exam/participate/summary/exam-result-summary.component'; +import { SubmissionType } from 'app/entities/submission.model'; + +@Component({ + selector: 'jhi-result-summary-exercise-card-header', + templateUrl: './exam-result-summary-exercise-card-header.component.html', + styleUrls: ['./exam-result-summary-exercise-card-header.component.scss'], +}) +export class ExamResultSummaryExerciseCardHeaderComponent { + @Input() index: number; + @Input() exercise: Exercise; + @Input() exerciseInfo?: ResultSummaryExerciseInfo; + @Input() resultsPublished: boolean; + + faAngleRight = faAngleRight; + + toggleCollapseExercise() { + this.exerciseInfo!.isCollapsed = !this.exerciseInfo!.isCollapsed; + } + + readonly SUBMISSION_TYPE_ILLEGAL = SubmissionType.ILLEGAL; +} diff --git a/src/main/webapp/app/exam/participate/summary/exercises/modeling-exam-summary/modeling-exam-summary.component.html b/src/main/webapp/app/exam/participate/summary/exercises/modeling-exam-summary/modeling-exam-summary.component.html index b694ad605a0a..e3b4e1121bc2 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/modeling-exam-summary/modeling-exam-summary.component.html +++ b/src/main/webapp/app/exam/participate/summary/exercises/modeling-exam-summary/modeling-exam-summary.component.html @@ -1,12 +1,16 @@ -
- +
+ + + +
No submission
+
- -
No submission
-
diff --git a/src/main/webapp/app/exam/participate/summary/exercises/modeling-exam-summary/modeling-exam-summary.component.ts b/src/main/webapp/app/exam/participate/summary/exercises/modeling-exam-summary/modeling-exam-summary.component.ts index 5101d09a3e0d..b8cafb1dda8b 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/modeling-exam-summary/modeling-exam-summary.component.ts +++ b/src/main/webapp/app/exam/participate/summary/exercises/modeling-exam-summary/modeling-exam-summary.component.ts @@ -1,32 +1,15 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { ModelingSubmission } from 'app/entities/modeling-submission.model'; import { ModelingExercise } from 'app/entities/modeling-exercise.model'; -import { UMLModel } from '@ls1intum/apollon'; @Component({ selector: 'jhi-modeling-exam-summary', templateUrl: './modeling-exam-summary.component.html', }) -export class ModelingExamSummaryComponent implements OnInit { - @Input() - exercise: ModelingExercise; - - @Input() - submission: ModelingSubmission; - - umlModel: UMLModel; - explanation: string; - - constructor() {} - - ngOnInit() { - if (this.submission) { - if (this.submission.model) { - this.umlModel = JSON.parse(this.submission.model); - } - if (this.submission.explanationText) { - this.explanation = this.submission.explanationText; - } - } - } +export class ModelingExamSummaryComponent { + @Input() exercise: ModelingExercise; + @Input() submission: ModelingSubmission; + @Input() isPrinting?: boolean = false; + @Input() expandProblemStatement?: boolean = false; + @Input() isAfterResultsArePublished?: boolean = false; } diff --git a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html index e365f906a0c0..244d4e044ee9 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html +++ b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.html @@ -1,6 +1,56 @@ -
- -
- {{ 'artemisApp.exam.examSummary.lastCommitHash' | artemisTranslate }}: - {{ (submission?.commitHash | slice: 0 : 7) || ('artemisApp.exam.examSummary.noCommitHash' | artemisTranslate) }} -
+ +
+
+
+
+
{{ 'artemisApp.exam.examSummary.yourSubmission' | artemisTranslate }}
+ + +
+ + + {{ 'artemisApp.exam.examSummary.submissionLinkedToCommit' | artemisTranslate }} + + {{ commitHash || ('artemisApp.exam.examSummary.noCommitHash' | artemisTranslate) }} + + + {{ commitHash || ('artemisApp.exam.examSummary.noCommitHash' | artemisTranslate) }} + + +
+ + +
{{ 'artemisApp.exam.examSummary.assessment' | artemisTranslate }}
+ + +
+
+ +
+
{{ 'artemisApp.exam.examSummary.problemStatement' | artemisTranslate }}
+ +
+
+
+ + +
diff --git a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.ts b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.ts index e63c68b8165d..3c2a2cd3f9cf 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.ts +++ b/src/main/webapp/app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component.ts @@ -1,21 +1,88 @@ -import { Component, Input } from '@angular/core'; +import { Component, Input, OnInit, Optional } from '@angular/core'; import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; import { ProgrammingExerciseStudentParticipation } from 'app/entities/participation/programming-exercise-student-participation.model'; import { ProgrammingSubmission } from 'app/entities/programming-submission.model'; +import { AssessmentType } from 'app/entities/assessment-type.model'; +import { Exam } from 'app/entities/exam.model'; +import { ExerciseType } from 'app/entities/exercise.model'; +import { MissingResultInformation, evaluateTemplateStatus } from 'app/exercises/shared/result/result.utils'; +import { FeedbackComponentPreparedParams, prepareFeedbackComponentParameters } from 'app/exercises/shared/feedback/feedback.utils'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { ExerciseCacheService } from 'app/exercises/shared/exercise/exercise-cache.service'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { Result } from 'app/entities/result.model'; +import { createCommitUrl } from 'app/exercises/programming/shared/utils/programming-exercise.utils'; @Component({ selector: 'jhi-programming-exam-summary', templateUrl: './programming-exam-summary.component.html', }) -export class ProgrammingExamSummaryComponent { - @Input() - exercise: ProgrammingExercise; +export class ProgrammingExamSummaryComponent implements OnInit { + @Input() exercise: ProgrammingExercise; - @Input() - participation: ProgrammingExerciseStudentParticipation; + @Input() participation: ProgrammingExerciseStudentParticipation; - @Input() - submission: ProgrammingSubmission; + @Input() submission: ProgrammingSubmission; - constructor() {} + @Input() isTestRun?: boolean = false; + + @Input() exam: Exam; + + @Input() isAfterStudentReviewStart?: boolean = false; + + @Input() resultsPublished?: boolean = false; + + @Input() isPrinting?: boolean = false; + + @Input() isAfterResultsArePublished?: boolean = false; + + readonly PROGRAMMING: ExerciseType = ExerciseType.PROGRAMMING; + + protected readonly AssessmentType = AssessmentType; + protected readonly ProgrammingExercise = ProgrammingExercise; + protected readonly ExerciseType = ExerciseType; + + result: Result | undefined; + + feedbackComponentParameters: FeedbackComponentPreparedParams; + + commitUrl: string | undefined; + commitHash: string | undefined; + + constructor( + private exerciseService: ExerciseService, + @Optional() private exerciseCacheService: ExerciseCacheService, + private profileService: ProfileService, + ) {} + + ngOnInit() { + this.result = this.participation.results?.[0]; + this.commitHash = this.submission?.commitHash?.slice(0, 11); + + const isBuilding = false; + const missingResultInfo = MissingResultInformation.NONE; + + const templateStatus = evaluateTemplateStatus(this.exercise, this.participation, this.participation.results?.[0], isBuilding, missingResultInfo); + + if (this.result) { + this.feedbackComponentParameters = prepareFeedbackComponentParameters( + this.exercise, + this.result, + this.participation, + templateStatus, + this.exam.latestIndividualEndDate, + this.exerciseCacheService ?? this.exerciseService, + ); + } + + this.updateCommitUrl(); + } + + private updateCommitUrl() { + // Get active profiles, to distinguish between Bitbucket and GitLab for the commit link of the result + this.profileService.getProfileInfo().subscribe((profileInfo) => { + const commitHashURLTemplate = profileInfo?.commitHashURLTemplate; + this.commitUrl = createCommitUrl(commitHashURLTemplate, this.exercise.projectKey, this.participation, this.submission); + }); + } } diff --git a/src/main/webapp/app/exam/participate/summary/exercises/text-exam-summary/text-exam-summary.component.html b/src/main/webapp/app/exam/participate/summary/exercises/text-exam-summary/text-exam-summary.component.html index f478cbcb7882..d4931b385e00 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/text-exam-summary/text-exam-summary.component.html +++ b/src/main/webapp/app/exam/participate/summary/exercises/text-exam-summary/text-exam-summary.component.html @@ -1,4 +1,14 @@ -
{{ submission.text }}
- -
No submission
-
+
+ + +
No submission
+
+
diff --git a/src/main/webapp/app/exam/participate/summary/exercises/text-exam-summary/text-exam-summary.component.ts b/src/main/webapp/app/exam/participate/summary/exercises/text-exam-summary/text-exam-summary.component.ts index dd9c4ce34f23..5ea1bf223f68 100644 --- a/src/main/webapp/app/exam/participate/summary/exercises/text-exam-summary/text-exam-summary.component.ts +++ b/src/main/webapp/app/exam/participate/summary/exercises/text-exam-summary/text-exam-summary.component.ts @@ -1,19 +1,14 @@ import { Component, Input } from '@angular/core'; import { TextSubmission } from 'app/entities/text-submission.model'; +import { Exercise } from 'app/entities/exercise.model'; @Component({ selector: 'jhi-text-exam-summary', templateUrl: './text-exam-summary.component.html', - styles: [ - ` - :host { - white-space: pre-wrap; - display: block; - background-color: var(--exam-text-exam-summary-background); - } - `, - ], }) export class TextExamSummaryComponent { + @Input() exercise: Exercise; @Input() submission: TextSubmission; + @Input() expandProblemStatement?: boolean = false; + @Input() isAfterResultsArePublished?: boolean = false; } diff --git a/src/main/webapp/app/exam/participate/summary/result-overview/exam-result-overview.component.scss b/src/main/webapp/app/exam/participate/summary/result-overview/exam-result-overview.component.scss index 251508ae9922..f053df75c6b5 100644 --- a/src/main/webapp/app/exam/participate/summary/result-overview/exam-result-overview.component.scss +++ b/src/main/webapp/app/exam/participate/summary/result-overview/exam-result-overview.component.scss @@ -46,7 +46,6 @@ .icon-container { display: inline-block; vertical-align: middle; - horiz-align: center; } .icon { diff --git a/src/main/webapp/app/exam/participate/summary/result-overview/exam-result-overview.component.ts b/src/main/webapp/app/exam/participate/summary/result-overview/exam-result-overview.component.ts index 35e68a698083..b110664baa8b 100644 --- a/src/main/webapp/app/exam/participate/summary/result-overview/exam-result-overview.component.ts +++ b/src/main/webapp/app/exam/participate/summary/result-overview/exam-result-overview.component.ts @@ -1,17 +1,16 @@ import { ChangeDetectorRef, Component, Input, OnChanges, OnInit } from '@angular/core'; import dayjs from 'dayjs/esm'; -import { Exercise, IncludedInOverallScore, getIcon } from 'app/entities/exercise.model'; +import { IncludedInOverallScore } from 'app/entities/exercise.model'; import { ArtemisServerDateService } from 'app/shared/server-date.service'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { GradeType } from 'app/entities/grading-scale.model'; import { faAward, faClipboard } from '@fortawesome/free-solid-svg-icons'; -import { ExerciseResult, StudentExamWithGradeDTO } from 'app/exam/exam-scores/exam-score-dtos.model'; +import { StudentExamWithGradeDTO } from 'app/exam/exam-scores/exam-score-dtos.model'; import { BonusStrategy } from 'app/entities/bonus.model'; -import { evaluateTemplateStatus, getTextColorClass } from 'app/exercises/shared/result/result.utils'; import { faChevronRight } from '@fortawesome/free-solid-svg-icons'; import { roundScorePercentSpecifiedByCourseSettings } from 'app/shared/util/utils'; -import { getLatestResultOfStudentParticipation } from 'app/exercises/shared/participation/participation.utils'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { captureException } from '@sentry/angular-ivy'; type ExerciseInfo = { icon: IconProp; @@ -31,6 +30,7 @@ export class ExamResultOverviewComponent implements OnInit, OnChanges { @Input() studentExamWithGrade: StudentExamWithGradeDTO; @Input() isGradingKeyCollapsed: boolean = true; @Input() isBonusGradingKeyCollapsed: boolean = true; + @Input() exerciseInfos: Record; gradingScaleExists = false; isBonus = false; @@ -51,8 +51,6 @@ export class ExamResultOverviewComponent implements OnInit, OnChanges { overallAchievedPercentageRoundedByCourseSettings = 0; isBonusGradingKeyDisplayed = false; - exerciseInfos: Record; - /** * The points summary table will only be shown if: * - exam.publishResultsDate is set @@ -90,24 +88,6 @@ export class ExamResultOverviewComponent implements OnInit, OnChanges { (this.studentExamWithGrade.studentResult.overallScoreAchieved ?? 0) / 100, this.studentExamWithGrade.studentExam?.exam?.course, ); - - this.exerciseInfos = this.getExerciseInfos(); - } - - private getExerciseInfos() { - const exerciseInfos: Record = {}; - for (const exercise of this.studentExamWithGrade?.studentExam?.exercises ?? []) { - if (exercise.id === undefined) { - console.error('Exercise id is undefined', exercise); - continue; - } - exerciseInfos[exercise.id] = { - icon: getIcon(exercise.type), - achievedPercentage: this.getAchievedPercentageByExerciseId(exercise.id), - colorClass: this.getTextColorClassByExercise(exercise), - }; - } - return exerciseInfos; } /** @@ -150,43 +130,6 @@ export class ExamResultOverviewComponent implements OnInit, OnChanges { return this.maxPoints + maxAchievableBonusPoints; } - private getExerciseResultByExerciseId(exerciseId?: number): ExerciseResult | undefined { - if (exerciseId === undefined) { - return undefined; - } - - const exerciseGroupResultMapping = this.studentExamWithGrade?.studentResult?.exerciseGroupIdToExerciseResult; - let exerciseResult = undefined; - - for (const key in exerciseGroupResultMapping) { - if (key in exerciseGroupResultMapping && exerciseGroupResultMapping[key].exerciseId === exerciseId) { - exerciseResult = exerciseGroupResultMapping[key]; - break; - } - } - - return exerciseResult; - } - - getAchievedPercentageByExerciseId(exerciseId?: number): number | undefined { - const result = this.getExerciseResultByExerciseId(exerciseId); - if (result === undefined) { - return undefined; - } - - const course = this.studentExamWithGrade.studentExam?.exam?.course; - if (result.achievedScore !== undefined) { - return roundScorePercentSpecifiedByCourseSettings(result.achievedScore / 100, course); - } - - const canCalculatePercentage = result.maxScore && result.achievedPoints !== undefined; - if (canCalculatePercentage) { - return roundScorePercentSpecifiedByCourseSettings(result.achievedPoints! / result.maxScore, course); - } - - return undefined; - } - scrollToExercise(exerciseId?: number) { if (exerciseId === undefined) { return; @@ -202,7 +145,15 @@ export class ExamResultOverviewComponent implements OnInit, OnChanges { inline: 'nearest', }); } else { - console.error(`Could not find corresponding exercise with id "${searchedId}"`); + const errorMessage = 'Cannot scroll to exercise, could not find exercise with corresponding id'; + console.error(errorMessage); + captureException(new Error(errorMessage), { + extra: { + exerciseId, + searchedId, + targetElement, + }, + }); } } @@ -216,17 +167,6 @@ export class ExamResultOverviewComponent implements OnInit, OnChanges { return false; } - getTextColorClassByExercise(exercise: Exercise) { - const participation = exercise.studentParticipations![0]; - const showUngradedResults = false; - const result = getLatestResultOfStudentParticipation(participation, showUngradedResults); - - const isBuilding = false; - const templateStatus = evaluateTemplateStatus(exercise, participation, result, isBuilding); - - return getTextColorClass(result, templateStatus); - } - toggleGradingKey(): void { this.isGradingKeyCollapsed = !this.isGradingKeyCollapsed; } @@ -234,8 +174,4 @@ export class ExamResultOverviewComponent implements OnInit, OnChanges { toggleBonusGradingKey(): void { this.isBonusGradingKeyCollapsed = !this.isBonusGradingKeyCollapsed; } - - protected readonly getIcon = getIcon; - protected readonly getTextColorClass = getTextColorClass; - protected readonly evaluateTemplateStatus = evaluateTemplateStatus; } diff --git a/src/main/webapp/app/exercises/file-upload/participate/file-upload-participation.module.ts b/src/main/webapp/app/exercises/file-upload/participate/file-upload-participation.module.ts index 3ddf94a20282..19a5d3e8d7fe 100644 --- a/src/main/webapp/app/exercises/file-upload/participate/file-upload-participation.module.ts +++ b/src/main/webapp/app/exercises/file-upload/participate/file-upload-participation.module.ts @@ -21,5 +21,6 @@ import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; ArtemisMarkdownModule, ], declarations: [FileUploadSubmissionComponent], + exports: [FileUploadSubmissionComponent], }) export class ArtemisFileUploadParticipationModule {} diff --git a/src/main/webapp/app/exercises/file-upload/participate/file-upload-submission.component.html b/src/main/webapp/app/exercises/file-upload/participate/file-upload-submission.component.html index 16fa6c5f61c1..9888815e7b8c 100644 --- a/src/main/webapp/app/exercises/file-upload/participate/file-upload-submission.component.html +++ b/src/main/webapp/app/exercises/file-upload/participate/file-upload-submission.component.html @@ -1,4 +1,4 @@ - + {{ 'artemisApp.fileUploadSubmission.fileUpload' | artemisTranslate }}: {{ examMode ? fileUploadExercise?.exerciseGroup?.title : fileUploadExercise?.title }} @@ -17,7 +17,7 @@ - +
@@ -40,14 +40,29 @@
-
+
{{ 'artemisApp.fileUploadSubmission.submittedFile' | artemisTranslate: { filename: submittedFileName } }} {{ submission!.submissionDate! | artemisTimeAgo }}
- Download file - - {{ submittedFileExtension | uppercase }} - + +
{{ 'artemisApp.exam.examSummary.yourSubmission' | artemisTranslate }}
+ {{ 'artemisApp.fileUploadSubmission.submittedFile' | artemisTranslate: { filename: submittedFileName } }} +
+ +
+ +
+ + + {{ submittedFileExtension | uppercase }} + +

diff --git a/src/main/webapp/app/exercises/file-upload/participate/file-upload-submission.component.ts b/src/main/webapp/app/exercises/file-upload/participate/file-upload-submission.component.ts index 27ad6505a4e1..ebd49234be13 100644 --- a/src/main/webapp/app/exercises/file-upload/participate/file-upload-submission.component.ts +++ b/src/main/webapp/app/exercises/file-upload/participate/file-upload-submission.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; import { Location } from '@angular/common'; import { TranslateService } from '@ngx-translate/core'; import { ActivatedRoute } from '@angular/router'; @@ -28,13 +28,25 @@ import { onError } from 'app/shared/util/global.utils'; import { getCourseFromExercise } from 'app/entities/exercise.model'; import { Course } from 'app/entities/course.model'; import { faListAlt } from '@fortawesome/free-regular-svg-icons'; +import { faDownload } from '@fortawesome/free-solid-svg-icons'; @Component({ + selector: 'jhi-file-upload-submission', templateUrl: './file-upload-submission.component.html', }) export class FileUploadSubmissionComponent implements OnInit, ComponentCanDeactivate { readonly addParticipationToResult = addParticipationToResult; @ViewChild('fileInput', { static: false }) fileInput: ElementRef; + + @Input() participationId?: number; + @Input() displayHeader: boolean = true; + @Input() expandProblemStatement?: boolean = true; + @Input() displayedInExamSummary?: boolean = false; + + @Input() inputExercise?: FileUploadExercise; + @Input() inputSubmission?: FileUploadSubmission; + @Input() inputParticipation?: StudentParticipation; + submission?: FileUploadSubmission; submittedFileName: string; submittedFileExtension: string; @@ -54,6 +66,7 @@ export class FileUploadSubmissionComponent implements OnInit, ComponentCanDeacti isLate: boolean; // indicates if the submission is late + faDownload = faDownload; readonly ButtonType = ButtonType; private submissionConfirmationText: string; @@ -81,55 +94,86 @@ export class FileUploadSubmissionComponent implements OnInit, ComponentCanDeacti * Initializes data for file upload editor */ ngOnInit() { - const participationId = Number(this.route.snapshot.paramMap.get('participationId')); - if (Number.isNaN(participationId)) { - return this.alertService.error('artemisApp.fileUploadExercise.error'); - } - this.fileUploadSubmissionService.getDataForFileUploadEditor(participationId).subscribe({ - next: (submission: FileUploadSubmission) => { - // reconnect participation <--> result - const tmpResult = getLatestSubmissionResult(submission); - if (tmpResult) { - submission.participation!.results = [tmpResult!]; - } - this.participation = submission.participation; + if (this.inputValuesArePresent()) { + this.setupComponentWithInputValues(); + } else { + const participationId = this.participationId ?? Number(this.route.snapshot.paramMap.get('participationId')); + if (Number.isNaN(participationId)) { + return this.alertService.error('artemisApp.fileUploadExercise.error'); + } + this.fileUploadSubmissionService.getDataForFileUploadEditor(participationId).subscribe({ + next: (submission: FileUploadSubmission) => { + // reconnect participation <--> result + const tmpResult = getLatestSubmissionResult(submission); + if (tmpResult) { + submission.participation!.results = [tmpResult!]; + } + this.participation = submission.participation; - // reconnect participation <--> submission - this.participation.submissions = [omit(submission, 'participation')]; + // reconnect participation <--> submission + this.participation.submissions = [omit(submission, 'participation')]; - this.submission = submission; - this.result = tmpResult!; - this.resultWithComplaint = getFirstResultWithComplaint(submission); - this.fileUploadExercise = this.participation.exercise as FileUploadExercise; - this.examMode = !!this.fileUploadExercise.exerciseGroup; - this.fileUploadExercise.studentParticipations = [this.participation]; - this.course = getCourseFromExercise(this.fileUploadExercise); + this.submission = submission; + this.result = tmpResult!; + this.resultWithComplaint = getFirstResultWithComplaint(submission); + this.fileUploadExercise = this.participation.exercise as FileUploadExercise; + this.examMode = !!this.fileUploadExercise.exerciseGroup; + this.fileUploadExercise.studentParticipations = [this.participation]; + this.course = getCourseFromExercise(this.fileUploadExercise); - // checks if the student started the exercise after the due date - this.isLate = - this.fileUploadExercise && - !!this.fileUploadExercise.dueDate && - !!this.participation.initializationDate && - dayjs(this.participation.initializationDate).isAfter(getExerciseDueDate(this.fileUploadExercise, this.participation)); + // checks if the student started the exercise after the due date + this.isLate = + this.fileUploadExercise && + !!this.fileUploadExercise.dueDate && + !!this.participation.initializationDate && + dayjs(this.participation.initializationDate).isAfter(getExerciseDueDate(this.fileUploadExercise, this.participation)); - this.acceptedFileExtensions = this.fileUploadExercise - .filePattern!.split(',') - .map((extension) => `.${extension}`) - .join(','); - this.isAfterAssessmentDueDate = !this.fileUploadExercise.assessmentDueDate || dayjs().isAfter(this.fileUploadExercise.assessmentDueDate); + this.acceptedFileExtensions = this.fileUploadExercise + .filePattern!.split(',') + .map((extension) => `.${extension}`) + .join(','); + this.isAfterAssessmentDueDate = !this.fileUploadExercise.assessmentDueDate || dayjs().isAfter(this.fileUploadExercise.assessmentDueDate); - if (this.submission?.submitted) { - this.setSubmittedFile(); - } - if (this.submission?.submitted && this.result?.completionDate) { - this.fileUploadAssessmentService.getAssessment(this.submission.id!).subscribe((assessmentResult: Result) => { - this.result = assessmentResult; - }); - } - this.isOwnerOfParticipation = this.accountService.isOwnerOfParticipation(this.participation); - }, - error: (error: HttpErrorResponse) => onError(this.alertService, error), - }); + if (this.submission?.submitted) { + this.setSubmittedFile(); + } + if (this.submission?.submitted && this.result?.completionDate) { + this.fileUploadAssessmentService.getAssessment(this.submission.id!).subscribe((assessmentResult: Result) => { + this.result = assessmentResult; + }); + } + this.isOwnerOfParticipation = this.accountService.isOwnerOfParticipation(this.participation); + }, + error: (error: HttpErrorResponse) => onError(this.alertService, error), + }); + } + } + + private inputValuesArePresent(): boolean { + return !!(this.inputExercise || this.inputSubmission || this.inputParticipation); + } + + /** + * Uses values directly passed to this component instead of subscribing to a participation to save resources + * + * e.g. used within {@link ExamResultSummaryComponent} and the respective {@link ModelingExamSummaryComponent} + * as directly after the exam no grading is present and only the student solution shall be displayed + * @private + */ + private setupComponentWithInputValues() { + if (this.inputExercise) { + this.fileUploadExercise = this.inputExercise; + } + if (this.inputSubmission) { + this.submission = this.inputSubmission; + } + if (this.inputParticipation) { + this.participation = this.inputParticipation; + } + + if (this.submission?.submitted) { + this.setSubmittedFile(); + } } /** diff --git a/src/main/webapp/app/exercises/modeling/participate/modeling-participation.module.ts b/src/main/webapp/app/exercises/modeling/participate/modeling-participation.module.ts index 267bc4d2d365..ac46c46cb406 100644 --- a/src/main/webapp/app/exercises/modeling/participate/modeling-participation.module.ts +++ b/src/main/webapp/app/exercises/modeling/participate/modeling-participation.module.ts @@ -31,5 +31,6 @@ import { ArtemisTeamParticipeModule } from 'app/exercises/shared/team/team-parti ArtemisTeamParticipeModule, ], declarations: [ModelingSubmissionComponent], + exports: [ModelingSubmissionComponent], }) export class ArtemisModelingParticipationModule {} diff --git a/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.html b/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.html index 358aa96607c0..bb9eb96f3adc 100644 --- a/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.html +++ b/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.html @@ -1,5 +1,5 @@
- + {{ 'artemisApp.modelingSubmission.modelingEditor' | artemisTranslate }}: {{ examMode ? modelingExercise?.exerciseGroup?.title : modelingExercise?.title }} @@ -18,7 +18,7 @@ > - +
diff --git a/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.ts b/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.ts index 29a0931f2c48..2fb1f7921db4 100644 --- a/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.ts +++ b/src/main/webapp/app/exercises/modeling/participate/modeling-submission.component.ts @@ -1,6 +1,6 @@ import { HttpErrorResponse } from '@angular/common/http'; -import { Component, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Component, HostListener, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { Selection, UMLElementType, UMLModel, UMLRelationshipType } from '@ls1intum/apollon'; import { TranslateService } from '@ngx-translate/core'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; @@ -33,6 +33,7 @@ import { Course } from 'app/entities/course.model'; import { getNamesForAssessments } from '../assess/modeling-assessment.util'; import { faExclamationTriangle, faGripLines } from '@fortawesome/free-solid-svg-icons'; import { faListAlt } from '@fortawesome/free-regular-svg-icons'; +import { onError } from 'app/shared/util/global.utils'; @Component({ selector: 'jhi-modeling-submission', @@ -47,6 +48,15 @@ export class ModelingSubmissionComponent implements OnInit, OnDestroy, Component modelingEditor: ModelingEditorComponent; ButtonType = ButtonType; + @Input() participationId?: number; + @Input() displayHeader: boolean = true; + @Input() isPrinting?: boolean = false; + @Input() expandProblemStatement?: boolean = false; + + @Input() inputExercise?: ModelingExercise; + @Input() inputSubmission?: ModelingSubmission; + @Input() inputParticipation?: StudentParticipation; + private subscription: Subscription; private resultUpdateListener: Subscription; @@ -105,7 +115,6 @@ export class ModelingSubmissionComponent implements OnInit, OnDestroy, Component private alertService: AlertService, private route: ActivatedRoute, private translateService: TranslateService, - private router: Router, private participationWebsocketService: ParticipationWebsocketService, private guidedTourService: GuidedTourService, private accountService: AccountService, @@ -116,29 +125,66 @@ export class ModelingSubmissionComponent implements OnInit, OnDestroy, Component } ngOnInit(): void { - this.subscription = this.route.params.subscribe((params) => { - if (params['participationId']) { - this.modelingSubmissionService.getLatestSubmissionForModelingEditor(params['participationId']).subscribe({ - next: (modelingSubmission) => { - this.updateModelingSubmission(modelingSubmission); - if (this.modelingExercise.teamMode) { - this.setupSubmissionStreamForTeam(); - } else { - this.setAutoSaveTimer(); - } - }, - error: (error) => { - if (error.status === 403) { - this.router.navigate(['accessdenied']); - } - }, - }); - } - }); - window.scroll(0, 0); + if (this.inputValuesArePresent()) { + this.setupComponentWithInputValues(); + } else { + this.subscription = this.route.params.subscribe((params) => { + const participationId = params['participationId'] ?? this.participationId; + + if (participationId) { + this.modelingSubmissionService.getLatestSubmissionForModelingEditor(participationId).subscribe({ + next: (modelingSubmission) => { + this.updateModelingSubmission(modelingSubmission); + if (this.modelingExercise.teamMode) { + this.setupSubmissionStreamForTeam(); + } else { + this.setAutoSaveTimer(); + } + }, + error: (error: HttpErrorResponse) => onError(this.alertService, error), + }); + } + }); + } + + const isDisplayedOnExamSummaryPage = !this.displayHeader && this.participationId !== undefined; + if (!isDisplayedOnExamSummaryPage) { + window.scroll(0, 0); + } + } + + private inputValuesArePresent(): boolean { + return !!(this.inputExercise || this.inputSubmission || this.inputParticipation); + } + + /** + * Uses values directly passed to this component instead of subscribing to a participation to save resources + * + * e.g. used within {@link ExamResultSummaryComponent} and the respective {@link ModelingExamSummaryComponent} + * as directly after the exam no grading is present and only the student solution shall be displayed + * @private + */ + private setupComponentWithInputValues() { + if (this.inputExercise) { + this.modelingExercise = this.inputExercise; + } + if (this.inputSubmission) { + this.submission = this.inputSubmission; + } + if (this.inputParticipation) { + this.participation = this.inputParticipation; + } + + if (this.submission.model) { + this.umlModel = JSON.parse(this.submission.model); + this.hasElements = this.umlModel.elements && this.umlModel.elements.length !== 0; + } + this.explanation = this.submission.explanationText ?? ''; } - // Updates component with the given modeling submission + /** + * Updates the modeling submission with the given modeling submission. + */ private updateModelingSubmission(modelingSubmission: ModelingSubmission) { if (!modelingSubmission) { this.alertService.error('artemisApp.apollonDiagram.submission.noSubmission'); @@ -419,7 +465,7 @@ export class ModelingSubmissionComponent implements OnInit, OnDestroy, Component } /** - * Check whether or not a assessmentResult exists and if, returns the unreferenced feedback of it + * Check whether a assessmentResult exists and if, returns the unreferenced feedback of it */ get unreferencedFeedback(): Feedback[] | undefined { if (this.assessmentResult?.feedbacks) { diff --git a/src/main/webapp/app/exercises/modeling/shared/modeling-editor.component.ts b/src/main/webapp/app/exercises/modeling/shared/modeling-editor.component.ts index 6413146e65d0..b3b744d6b8d3 100644 --- a/src/main/webapp/app/exercises/modeling/shared/modeling-editor.component.ts +++ b/src/main/webapp/app/exercises/modeling/shared/modeling-editor.component.ts @@ -1,6 +1,5 @@ -import { AfterViewInit, Component, EventEmitter, Input, OnChanges, OnDestroy, Output, Renderer2, SimpleChanges } from '@angular/core'; +import { AfterViewInit, Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges } from '@angular/core'; import { ApollonEditor, ApollonMode, UMLDiagramType, UMLElementType, UMLModel, UMLRelationship, UMLRelationshipType } from '@ls1intum/apollon'; -import { AlertService } from 'app/core/util/alert.service'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { associationUML, personUML, studentUML } from 'app/guided-tour/guided-tour-task.model'; import { GuidedTourService } from 'app/guided-tour/guided-tour.service'; @@ -36,8 +35,6 @@ export class ModelingEditorComponent extends ModelingComponent implements AfterV scrollListener: ((this: Document, ev: Event) => any) | undefined; constructor( - private alertService: AlertService, - private renderer: Renderer2, private modalService: NgbModal, private guidedTourService: GuidedTourService, ) { diff --git a/src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-example-solution-repo-download.component.ts b/src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-example-solution-repo-download.component.ts index 237db5201818..493688bff21a 100644 --- a/src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-example-solution-repo-download.component.ts +++ b/src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-example-solution-repo-download.component.ts @@ -6,18 +6,20 @@ import { ProgrammingExerciseInstructorRepoDownloadComponent } from 'app/exercise selector: 'jhi-programming-exercise-example-solution-repo-download', template: ``, + />`, }) export class ProgrammingExerciseExampleSolutionRepoDownloadComponent extends ProgrammingExerciseInstructorRepoDownloadComponent { @Input() includeTests?: boolean; + @Input() displayedOnExamSummary: boolean = false; + exportRepository() { if (this.exerciseId) { this.programmingExerciseService.exportStudentRequestedRepository(this.exerciseId, this.includeTests ?? false).subscribe((response) => { diff --git a/src/main/webapp/app/exercises/programming/shared/lifecycle/programming-exercise-lifecycle.component.html b/src/main/webapp/app/exercises/programming/shared/lifecycle/programming-exercise-lifecycle.component.html index f2718b6a0e0f..3605f0d5d40d 100644 --- a/src/main/webapp/app/exercises/programming/shared/lifecycle/programming-exercise-lifecycle.component.html +++ b/src/main/webapp/app/exercises/programming/shared/lifecycle/programming-exercise-lifecycle.component.html @@ -9,7 +9,7 @@ class="test-schedule-date px-1" label="artemisApp.exercise.releaseDate" tooltipText="artemisApp.programmingExercise.timeline.releaseDateTooltip" - > + /> + />
@@ -44,8 +44,8 @@ class="test-schedule-date px-1" label="artemisApp.exercise.dueDate" tooltipText="artemisApp.programmingExercise.timeline.dueDateTooltip" - > - + id="programming-exercise-due-date-picker" + /> - + />
@@ -94,7 +93,7 @@ [readOnly]="readOnly" label="artemisApp.programmingExercise.timeline.assessmentDueDate" tooltipText="artemisApp.programmingExercise.timeline.assessmentDueDateTooltip" - > + />
@@ -106,8 +105,7 @@ class="test-schedule-date px-1" label="artemisApp.exercise.exampleSolutionPublicationDate" tooltipText="artemisApp.programmingExercise.timeline.exampleSolutionPublicationDateTooltip" - > - + />
diff --git a/src/main/webapp/app/exercises/shared/example-solution/example-solution.component.html b/src/main/webapp/app/exercises/shared/example-solution/example-solution.component.html index a74312f82e3b..c6fdb1cdcf67 100644 --- a/src/main/webapp/app/exercises/shared/example-solution/example-solution.component.html +++ b/src/main/webapp/app/exercises/shared/example-solution/example-solution.component.html @@ -1,5 +1,5 @@
- + {{ exercise?.exerciseGroup ? exercise.exerciseGroup!.title : exercise!.title }}
diff --git a/src/main/webapp/app/exercises/shared/example-solution/example-solution.component.ts b/src/main/webapp/app/exercises/shared/example-solution/example-solution.component.ts index 537908392eeb..803dc8b97127 100644 --- a/src/main/webapp/app/exercises/shared/example-solution/example-solution.component.ts +++ b/src/main/webapp/app/exercises/shared/example-solution/example-solution.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { HttpResponse } from '@angular/common/http'; import { ActivatedRoute } from '@angular/router'; import { Exercise } from 'app/entities/exercise.model'; @@ -10,10 +10,13 @@ import { ArtemisMarkdownService } from 'app/shared/markdown.service'; templateUrl: './example-solution.component.html', }) export class ExampleSolutionComponent implements OnInit { - private exerciseId: number; + private displayedExerciseId: number; public exercise?: Exercise; public exampleSolutionInfo?: ExampleSolutionInfo; + @Input() exerciseId?: number; + @Input() displayHeader?: boolean = true; + constructor( private exerciseService: ExerciseService, private route: ActivatedRoute, @@ -22,8 +25,10 @@ export class ExampleSolutionComponent implements OnInit { ngOnInit() { this.route.params.subscribe((params) => { - const didExerciseChange = this.exerciseId !== parseInt(params['exerciseId'], 10); - this.exerciseId = parseInt(params['exerciseId'], 10); + const exerciseId = this.exerciseId || parseInt(params['exerciseId'], 10); + + const didExerciseChange = this.displayedExerciseId !== exerciseId; + this.displayedExerciseId = exerciseId; if (didExerciseChange) { this.loadExercise(); } @@ -32,7 +37,7 @@ export class ExampleSolutionComponent implements OnInit { loadExercise() { this.exercise = undefined; - this.exerciseService.getExerciseForExampleSolution(this.exerciseId).subscribe((exerciseResponse: HttpResponse) => { + this.exerciseService.getExerciseForExampleSolution(this.displayedExerciseId).subscribe((exerciseResponse: HttpResponse) => { const newExercise = exerciseResponse.body!; this.exercise = newExercise; this.exampleSolutionInfo = ExerciseService.extractExampleSolutionInfo(newExercise, this.artemisMarkdown); diff --git a/src/main/webapp/app/exercises/shared/feedback/feedback.component.html b/src/main/webapp/app/exercises/shared/feedback/feedback.component.html index 4825a7b056c9..aa950493300a 100644 --- a/src/main/webapp/app/exercises/shared/feedback/feedback.component.html +++ b/src/main/webapp/app/exercises/shared/feedback/feedback.component.html @@ -1,4 +1,4 @@ -
- +
diff --git a/src/main/webapp/app/exercises/shared/feedback/standalone-feedback/standalone-feedback.component.html b/src/main/webapp/app/exercises/shared/feedback/standalone-feedback/standalone-feedback.component.html index 1d2ad2a8369a..77c9e0b9c2ed 100644 --- a/src/main/webapp/app/exercises/shared/feedback/standalone-feedback/standalone-feedback.component.html +++ b/src/main/webapp/app/exercises/shared/feedback/standalone-feedback/standalone-feedback.component.html @@ -8,4 +8,4 @@ [messageKey]="messageKey" [showMissingAutomaticFeedbackInformation]="showMissingAutomaticFeedbackInformation" class="modal-padding" -> +/> diff --git a/src/main/webapp/app/exercises/shared/result/result.component.ts b/src/main/webapp/app/exercises/shared/result/result.component.ts index 38ec6b36586f..cc2cd33b6973 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.ts +++ b/src/main/webapp/app/exercises/shared/result/result.component.ts @@ -18,13 +18,13 @@ import { AssessmentType } from 'app/entities/assessment-type.model'; import { roundValueSpecifiedByCourseSettings } from 'app/shared/util/utils'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { captureException } from '@sentry/angular-ivy'; -import { hasExerciseDueDatePassed } from 'app/exercises/shared/exercise/exercise.utils'; import { faCircleNotch, faExclamationCircle, faExclamationTriangle, faFile } from '@fortawesome/free-solid-svg-icons'; import { faCircle } from '@fortawesome/free-regular-svg-icons'; import { Badge, ResultService } from 'app/exercises/shared/result/result.service'; import { ExerciseCacheService } from 'app/exercises/shared/exercise/exercise-cache.service'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { isPracticeMode } from 'app/entities/participation/student-participation.model'; +import { prepareFeedbackComponentParameters } from 'app/exercises/shared/feedback/feedback.utils'; @Component({ selector: 'jhi-result', @@ -211,34 +211,35 @@ export class ResultComponent implements OnInit, OnChanges { * @param result Result object whose details will be displayed. */ showDetails(result: Result) { - if (!result.participation) { - result.participation = this.participation; - } + const exerciseService = this.exerciseCacheService ?? this.exerciseService; + const feedbackComponentParameters = prepareFeedbackComponentParameters(this.exercise, result, this.participation, this.templateStatus, this.latestDueDate, exerciseService); if (this.exercise?.type === ExerciseType.QUIZ) { // There is no feedback for quiz exercises. // Instead, the scoring is showed next to the different questions - return; + return undefined; } const modalRef = this.modalService.open(FeedbackComponent, { keyboard: true, size: 'xl' }); - const componentInstance: FeedbackComponent = modalRef.componentInstance; - componentInstance.exercise = this.exercise; - componentInstance.result = result; - if (this.exercise) { - componentInstance.exerciseType = this.exercise.type!; - componentInstance.showScoreChart = true; + const modalComponentInstance: FeedbackComponent = modalRef.componentInstance; + + modalComponentInstance.exercise = this.exercise; + modalComponentInstance.result = result; + if (feedbackComponentParameters.exerciseType) { + modalComponentInstance.exerciseType = feedbackComponentParameters.exerciseType; } - if (this.templateStatus === ResultTemplateStatus.MISSING) { - componentInstance.messageKey = 'artemisApp.result.notLatestSubmission'; + if (feedbackComponentParameters.showScoreChart) { + modalComponentInstance.showScoreChart = feedbackComponentParameters.showScoreChart; } - - if ( - this.result?.assessmentType === AssessmentType.AUTOMATIC && - this.exercise?.type === ExerciseType.PROGRAMMING && - hasExerciseDueDatePassed(this.exercise, this.participation) - ) { - this.determineShowMissingAutomaticFeedbackInformation(componentInstance); + if (feedbackComponentParameters.messageKey) { + modalComponentInstance.messageKey = feedbackComponentParameters.messageKey; + } + if (feedbackComponentParameters.latestDueDate) { + this.latestDueDate = feedbackComponentParameters.latestDueDate; + modalComponentInstance.latestDueDate = feedbackComponentParameters.latestDueDate; + } + if (feedbackComponentParameters.showMissingAutomaticFeedbackInformation) { + modalComponentInstance.showMissingAutomaticFeedbackInformation = feedbackComponentParameters.showMissingAutomaticFeedbackInformation; } } @@ -270,27 +271,4 @@ export class ResultComponent implements OnInit, OnChanges { }); } } - - /** - * Determines if some information about testcases could still be hidden because of later individual due dates - * @param componentInstance the detailed result view - */ - private determineShowMissingAutomaticFeedbackInformation(componentInstance: FeedbackComponent) { - if (this.latestDueDate) { - this.setShowMissingAutomaticFeedbackInformation(componentInstance, this.latestDueDate); - } else { - const service = this.exerciseCacheService ?? this.exerciseService; - service.getLatestDueDate(this.exercise!.id!).subscribe((latestDueDate) => { - if (latestDueDate) { - this.setShowMissingAutomaticFeedbackInformation(componentInstance, latestDueDate); - } - }); - } - } - - private setShowMissingAutomaticFeedbackInformation(componentInstance: FeedbackComponent, latestDueDate: dayjs.Dayjs) { - this.latestDueDate = latestDueDate; - componentInstance.showMissingAutomaticFeedbackInformation = dayjs().isBefore(latestDueDate); - componentInstance.latestDueDate = this.latestDueDate; - } } diff --git a/src/main/webapp/app/exercises/shared/result/updating-result.component.html b/src/main/webapp/app/exercises/shared/result/updating-result.component.html index cbe44d7d52bd..a9fce837e0cb 100644 --- a/src/main/webapp/app/exercises/shared/result/updating-result.component.html +++ b/src/main/webapp/app/exercises/shared/result/updating-result.component.html @@ -9,4 +9,4 @@ [showBadge]="showBadge" [showIcon]="showIcon" [missingResultInfo]="missingResultInfo" -> +/> diff --git a/src/main/webapp/app/exercises/text/participate/text-editor.component.html b/src/main/webapp/app/exercises/text/participate/text-editor.component.html index 788fa02449b2..83dba66c2b22 100644 --- a/src/main/webapp/app/exercises/text/participate/text-editor.component.html +++ b/src/main/webapp/app/exercises/text/participate/text-editor.component.html @@ -1,5 +1,5 @@
- + {{ 'artemisApp.textSubmission.textEditor' | artemisTranslate }}: {{ examMode ? textExercise.exerciseGroup?.title : textExercise?.title }} @@ -19,7 +19,7 @@ - +
diff --git a/src/main/webapp/app/exercises/text/participate/text-editor.component.ts b/src/main/webapp/app/exercises/text/participate/text-editor.component.ts index a137c29c87af..75739504fd75 100644 --- a/src/main/webapp/app/exercises/text/participate/text-editor.component.ts +++ b/src/main/webapp/app/exercises/text/participate/text-editor.component.ts @@ -1,4 +1,4 @@ -import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { Component, HostListener, Input, OnDestroy, OnInit } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { ActivatedRoute } from '@angular/router'; import { HttpErrorResponse } from '@angular/common/http'; @@ -29,6 +29,7 @@ import { faListAlt } from '@fortawesome/free-regular-svg-icons'; import { MAX_SUBMISSION_TEXT_LENGTH } from 'app/shared/constants/input.constants'; @Component({ + selector: 'jhi-text-editor', templateUrl: './text-editor.component.html', providers: [ParticipationService], styleUrls: ['./text-editor.component.scss'], @@ -36,6 +37,15 @@ import { MAX_SUBMISSION_TEXT_LENGTH } from 'app/shared/constants/input.constants export class TextEditorComponent implements OnInit, OnDestroy, ComponentCanDeactivate { readonly ButtonType = ButtonType; readonly maxCharacterCount = MAX_SUBMISSION_TEXT_LENGTH; + + @Input() participationId?: number; + @Input() displayHeader: boolean = true; + @Input() expandProblemStatement?: boolean = true; + + @Input() inputExercise?: TextExercise; + @Input() inputSubmission?: TextSubmission; + @Input() inputParticipation?: StudentParticipation; + textExercise: TextExercise; participation: StudentParticipation; result: Result; @@ -77,15 +87,46 @@ export class TextEditorComponent implements OnInit, OnDestroy, ComponentCanDeact } ngOnInit() { - const participationId = Number(this.route.snapshot.paramMap.get('participationId')); - if (Number.isNaN(participationId)) { - return this.alertService.error('artemisApp.textExercise.error'); + if (this.inputValuesArePresent()) { + this.setupComponentWithInputValues(); + } else { + const participationId = this.participationId !== undefined ? this.participationId : Number(this.route.snapshot.paramMap.get('participationId')); + if (Number.isNaN(participationId)) { + return this.alertService.error('artemisApp.textExercise.error'); + } + + this.textService.get(participationId).subscribe({ + next: (data: StudentParticipation) => this.updateParticipation(data), + error: (error: HttpErrorResponse) => onError(this.alertService, error), + }); } + } - this.textService.get(participationId).subscribe({ - next: (data: StudentParticipation) => this.updateParticipation(data), - error: (error: HttpErrorResponse) => onError(this.alertService, error), - }); + private inputValuesArePresent(): boolean { + return !!(this.inputExercise || this.inputSubmission || this.inputParticipation); + } + + /** + * Uses values directly passed to this component instead of subscribing to a participation to save resources + * + * e.g. used within {@link ExamResultSummaryComponent} and the respective {@link ModelingExamSummaryComponent} + * as directly after the exam no grading is present and only the student solution shall be displayed + * @private + */ + private setupComponentWithInputValues() { + if (this.inputExercise) { + this.textExercise = this.inputExercise; + } + if (this.inputSubmission) { + this.submission = this.inputSubmission; + } + if (this.inputParticipation) { + this.participation = this.inputParticipation; + } + + if (this.submission?.text) { + this.answer = this.submission.text; + } } private updateParticipation(participation: StudentParticipation) { diff --git a/src/main/webapp/app/exercises/text/participate/text-participation.module.ts b/src/main/webapp/app/exercises/text/participate/text-participation.module.ts index 7e407cb6b61d..21e16e27b898 100644 --- a/src/main/webapp/app/exercises/text/participate/text-participation.module.ts +++ b/src/main/webapp/app/exercises/text/participate/text-participation.module.ts @@ -27,5 +27,6 @@ import { ArtemisTeamParticipeModule } from 'app/exercises/shared/team/team-parti ArtemisTeamParticipeModule, ], declarations: [TextEditorComponent, TextResultComponent], + exports: [TextEditorComponent], }) export class ArtemisTextParticipationModule {} diff --git a/src/main/webapp/app/grading-system/grading-key-overview/grading-key-overview.component.ts b/src/main/webapp/app/grading-system/grading-key-overview/grading-key-overview.component.ts index 37f9e5ecd13d..baa6d137e54e 100644 --- a/src/main/webapp/app/grading-system/grading-key-overview/grading-key-overview.component.ts +++ b/src/main/webapp/app/grading-system/grading-key-overview/grading-key-overview.component.ts @@ -63,7 +63,7 @@ export class GradingKeyOverviewComponent implements OnInit { /** * Exports page as PDF */ - printPDF() { - setTimeout(() => this.themeService.print()); + async printPDF() { + await this.themeService.print(); } } diff --git a/src/main/webapp/app/overview/course-exercises/course-exercise-row.module.ts b/src/main/webapp/app/overview/course-exercises/course-exercise-row.module.ts index 75cb891ff01a..24d2c08d157c 100644 --- a/src/main/webapp/app/overview/course-exercises/course-exercise-row.module.ts +++ b/src/main/webapp/app/overview/course-exercises/course-exercise-row.module.ts @@ -10,6 +10,8 @@ import { OrionModule } from 'app/shared/orion/orion.module'; import { GradingKeyOverviewModule } from 'app/grading-system/grading-key-overview/grading-key-overview.module'; import { SubmissionResultStatusModule } from 'app/overview/submission-result-status.module'; import { ExerciseCategoriesModule } from 'app/shared/exercise-categories/exercise-categories.module'; +import { CourseExercisesGroupedByWeekComponent } from 'app/overview/course-exercises/course-exercises-grouped-by-week.component'; +import { CourseExercisesGroupedByCategoryComponent } from 'app/overview/course-exercises/course-exercises-grouped-by-category.component'; @NgModule({ imports: [ @@ -24,7 +26,7 @@ import { ExerciseCategoriesModule } from 'app/shared/exercise-categories/exercis SubmissionResultStatusModule, ExerciseCategoriesModule, ], - declarations: [CourseExerciseRowComponent], - exports: [CourseExerciseRowComponent], + declarations: [CourseExerciseRowComponent, CourseExercisesGroupedByWeekComponent, CourseExercisesGroupedByCategoryComponent], + exports: [CourseExerciseRowComponent, CourseExercisesGroupedByWeekComponent, CourseExercisesGroupedByCategoryComponent], }) export class ArtemisCourseExerciseRowModule {} diff --git a/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-category.component.html b/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-category.component.html new file mode 100644 index 000000000000..303b3d09904b --- /dev/null +++ b/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-category.component.html @@ -0,0 +1,38 @@ +
+ {{ 'artemisApp.courseOverview.exerciseList.noExerciseMatchesSearchAndFilters' | artemisTranslate }} +
+ +
+ +
+
+ +
+ + +

{{ 'artemisApp.courseOverview.exerciseList.past' | artemisTranslate }}

+

{{ 'artemisApp.courseOverview.exerciseList.current' | artemisTranslate }}

+

{{ 'artemisApp.courseOverview.exerciseList.future' | artemisTranslate }}

+

{{ 'artemisApp.courseOverview.exerciseList.noDueDate' | artemisTranslate }}

+
+
+ +
+ +
+ + +
+
+
diff --git a/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-category.component.ts b/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-category.component.ts new file mode 100644 index 000000000000..46efea17fe2e --- /dev/null +++ b/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-category.component.ts @@ -0,0 +1,167 @@ +import { Component, Input, OnChanges } from '@angular/core'; +import { Exercise } from 'app/entities/exercise.model'; +import { Course } from 'app/entities/course.model'; +import dayjs from 'dayjs/esm/'; +import { faAngleDown, faAngleUp, faChevronRight } from '@fortawesome/free-solid-svg-icons'; +import { cloneDeep } from 'lodash-es'; + +type ExerciseGroupCategory = 'past' | 'current' | 'future' | 'noDueDate'; + +/** + * {@link ExerciseGroupCategory#past} is always collapsed by default + */ +const DEFAULT_EXPAND_ORDER: ExerciseGroupCategory[] = ['current', 'future', 'noDueDate']; + +type ExerciseGroups = Record; + +const DEFAULT_EXERCISE_GROUPS = { + past: { exercises: [], isCollapsed: true }, + current: { exercises: [], isCollapsed: false }, + future: { exercises: [], isCollapsed: false }, + noDueDate: { exercises: [], isCollapsed: true }, +}; + +@Component({ + selector: 'jhi-course-exercises-grouped-by-category', + templateUrl: './course-exercises-grouped-by-category.component.html', + styleUrls: ['../course-overview.scss'], +}) +export class CourseExercisesGroupedByCategoryComponent implements OnChanges { + protected readonly Object = Object; + + @Input() filteredExercises?: Exercise[]; + @Input() course?: Course; + @Input() exerciseForGuidedTour?: Exercise; + @Input() appliedSearchString?: string; + + exerciseGroups: ExerciseGroups; + + searchWasActive: boolean = false; + exerciseGroupsBeforeSearch: ExerciseGroups = cloneDeep(DEFAULT_EXERCISE_GROUPS); + + faAngleUp = faAngleUp; + faAngleDown = faAngleDown; + faChevronRight = faChevronRight; + + ngOnChanges() { + this.exerciseGroups = this.groupExercisesByDueDate(); + } + + toggleGroupCategoryCollapse(exerciseGroupCategoryKey: string) { + this.exerciseGroups[exerciseGroupCategoryKey].isCollapsed = !this.exerciseGroups[exerciseGroupCategoryKey].isCollapsed; + } + + private groupExercisesByDueDate(): ExerciseGroups { + const updatedExerciseGroups: ExerciseGroups = cloneDeep(DEFAULT_EXERCISE_GROUPS); + + if (!this.filteredExercises) { + return updatedExerciseGroups; + } + + for (const exercise of this.filteredExercises) { + const exerciseGroup = this.getExerciseGroup(exercise); + updatedExerciseGroups[exerciseGroup].exercises.push(exercise); + } + + this.adjustExpandedOrCollapsedStateOfExerciseGroups(updatedExerciseGroups); + + return updatedExerciseGroups; + } + + private expandAllExercisesAndSaveStateBeforeSearch(exerciseGroups: ExerciseGroups) { + const isAConsecutiveSearchWithAllGroupsExpanded = this.searchWasActive; + if (!isAConsecutiveSearchWithAllGroupsExpanded) { + this.exerciseGroupsBeforeSearch = cloneDeep(this.exerciseGroups); + this.searchWasActive = true; + } + + Object.entries(exerciseGroups).forEach(([, exerciseGroup]) => { + exerciseGroup.isCollapsed = false; + }); + } + + private restoreStateBeforeSearch(exerciseGroups: ExerciseGroups) { + this.searchWasActive = false; + + Object.entries(exerciseGroups).forEach(([exerciseGroupKey, exerciseGroup]) => { + exerciseGroup.isCollapsed = this.exerciseGroupsBeforeSearch[exerciseGroupKey].isCollapsed; + }); + } + + private keepCurrentCollapsedOrExpandedStateOfExerciseGroups(exerciseGroups: ExerciseGroups) { + Object.entries(exerciseGroups).forEach(([exerciseGroupKey, exerciseGroup]) => { + exerciseGroup.isCollapsed = this.exerciseGroups[exerciseGroupKey].isCollapsed; + }); + } + + /** + * Expand at least one exercise group, considering that {@link ExerciseGroupCategory#past} shall never be expanded by default + * + * Expanded by the order {@link ExerciseGroupCategory#current}, {@link ExerciseGroupCategory#future}, {@link ExerciseGroupCategory#noDueDate} + */ + private makeSureAtLeastOneExerciseGroupIsExpanded(exerciseGroups: ExerciseGroups) { + const exerciseGroupsWithExercises = Object.entries(exerciseGroups).filter(([, exerciseGroup]) => exerciseGroup.exercises.length > 0); + const expandedExerciseGroups = exerciseGroupsWithExercises.filter(([exerciseGroupKey, exerciseGroup]) => !exerciseGroup.isCollapsed && exerciseGroupKey !== 'past'); + + const atLeastOneExerciseIsExpanded = expandedExerciseGroups.length > 0; + const expandableGroupsExist = !atLeastOneExerciseIsExpanded && exerciseGroupsWithExercises.length > 0; + + if (!expandableGroupsExist) { + return; + } + + for (const exerciseGroupKey of DEFAULT_EXPAND_ORDER) { + const groupToExpand = exerciseGroupsWithExercises.find(([key]) => key === exerciseGroupKey); + if (groupToExpand) { + groupToExpand![1].isCollapsed = false; + break; + } + } + } + + /** + * 1. Expand all sections with matches on search + * 2. Keep the expanded or collapsed state of the exercise groups when a filter is applied + * 3. Make sure at least one displayed section is expanded by default + * + * @param exerciseGroups updated and grouped exercises that are to be displayed + */ + private adjustExpandedOrCollapsedStateOfExerciseGroups(exerciseGroups: ExerciseGroups) { + const isSearchingExercise = this.appliedSearchString; + if (isSearchingExercise) { + return this.expandAllExercisesAndSaveStateBeforeSearch(exerciseGroups); + } + + if (this.searchWasActive) { + return this.restoreStateBeforeSearch(exerciseGroups); + } + + const filterIsApplied = this.exerciseGroups; + if (filterIsApplied) { + this.keepCurrentCollapsedOrExpandedStateOfExerciseGroups(exerciseGroups); + } + + this.makeSureAtLeastOneExerciseGroupIsExpanded(exerciseGroups); + } + + private getExerciseGroup(exercise: Exercise): ExerciseGroupCategory { + if (!exercise.dueDate) { + return 'noDueDate'; + } + + const dueDate = dayjs(exercise.dueDate); + const now = dayjs(); + + const dueDateIsInThePast = dueDate.isBefore(now); + if (dueDateIsInThePast) { + return 'past'; + } + + const dueDateIsWithinNextWeek = dueDate.isBefore(now.add(1, 'week')); + if (dueDateIsWithinNextWeek) { + return 'current'; + } + + return 'future'; + } +} diff --git a/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-week.component.html b/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-week.component.html new file mode 100644 index 000000000000..4a40ce76f8e4 --- /dev/null +++ b/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-week.component.html @@ -0,0 +1,53 @@ +
+

+ + {{ 'artemisApp.courseOverview.exerciseList.currentExerciseGroupHeader' | artemisTranslate: { date: nextRelevantExercise.dueDate | artemisDate } }} + + + {{ 'artemisApp.courseOverview.exerciseList.currentExerciseGroupHeaderWithoutDueDate' | artemisTranslate }} + +

+ + +
+ +
+
+ + + {{ immutableWeeklyExercisesGrouped[weekKey].start | artemisDate: 'short-date' }} - + {{ immutableWeeklyExercisesGrouped[weekKey].end | artemisDate: 'short-date' }} + + + {{ 'artemisApp.courseOverview.exerciseList.noDateAssociated' | artemisTranslate }} + + + (Exercises: {{ immutableWeeklyExercisesGrouped[weekKey].exercises.length }}) + +
+
+ +
+ +
diff --git a/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-week.component.ts b/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-week.component.ts new file mode 100644 index 000000000000..384eb996e01e --- /dev/null +++ b/src/main/webapp/app/overview/course-exercises/course-exercises-grouped-by-week.component.ts @@ -0,0 +1,42 @@ +import { Component, Input, OnChanges, OnInit } from '@angular/core'; +import { Exercise } from 'app/entities/exercise.model'; +import { ExerciseFilter, ExerciseWithDueDate } from 'app/overview/course-exercises/course-exercises.component'; +import { Course } from 'app/entities/course.model'; +import { faAngleDown, faAngleUp } from '@fortawesome/free-solid-svg-icons'; +import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; +import { getAsMutableObject } from 'app/shared/util/utils'; + +@Component({ + selector: 'jhi-course-exercises-grouped-by-week', + templateUrl: './course-exercises-grouped-by-week.component.html', + styleUrls: ['../course-overview.scss'], +}) +export class CourseExercisesGroupedByWeekComponent implements OnInit, OnChanges { + @Input() nextRelevantExercise?: ExerciseWithDueDate; + @Input() course: Course; + @Input() exerciseForGuidedTour?: Exercise; + @Input() weeklyIndexKeys: string[]; + @Input() immutableWeeklyExercisesGrouped: object; + @Input() activeFilters: Set; + + weeklyExercisesGrouped: object; + + faAngleUp = faAngleUp; + faAngleDown = faAngleDown; + + ngOnInit() { + this.weeklyExercisesGrouped = getAsMutableObject(this.immutableWeeklyExercisesGrouped); + } + + ngOnChanges() { + this.weeklyExercisesGrouped = getAsMutableObject(this.immutableWeeklyExercisesGrouped); + } + + /** + * Checks whether an exercise is visible to students or not + * @param exercise The exercise which should be checked + */ + isVisibleToStudents(exercise: Exercise): boolean | undefined { + return !this.activeFilters.has(ExerciseFilter.UNRELEASED) || (exercise as QuizExercise)?.visibleToStudents; + } +} diff --git a/src/main/webapp/app/overview/course-exercises/course-exercises.component.html b/src/main/webapp/app/overview/course-exercises/course-exercises.component.html index 55424d73443d..53ce0c9646ba 100644 --- a/src/main/webapp/app/overview/course-exercises/course-exercises.component.html +++ b/src/main/webapp/app/overview/course-exercises/course-exercises.component.html @@ -1,6 +1,20 @@
+ +
-
-

- - {{ 'artemisApp.courseOverview.exerciseList.currentExerciseGroupHeader' | artemisTranslate: { date: nextRelevantExercise.dueDate | artemisDate } }} - - - {{ 'artemisApp.courseOverview.exerciseList.currentExerciseGroupHeaderWithoutDueDate' | artemisTranslate }} - -

- + - -
-
-
- - - {{ weeklyExercisesGrouped[weekKey].start | artemisDate: 'short-date' }} - - {{ weeklyExercisesGrouped[weekKey].end | artemisDate: 'short-date' }} - - - {{ 'artemisApp.courseOverview.exerciseList.noDateAssociated' | artemisTranslate }} - - - (Exercises: {{ weeklyExercisesGrouped[weekKey].exercises.length }}) - -
-
- -
- -
-
+ [exerciseForGuidedTour]="exerciseForGuidedTour" + [appliedSearchString]="lastAppliedSearchString" + /> + + + + +
{{ 'artemisApp.courseOverview.exerciseList.noExercises' | artemisTranslate }}
-
+
diff --git a/src/main/webapp/app/overview/course-exercises/course-exercises.component.ts b/src/main/webapp/app/overview/course-exercises/course-exercises.component.ts index c4fd548dec85..364eab48f11b 100644 --- a/src/main/webapp/app/overview/course-exercises/course-exercises.component.ts +++ b/src/main/webapp/app/overview/course-exercises/course-exercises.component.ts @@ -47,7 +47,7 @@ export enum SortingAttribute { RELEASE_DATE = 1, } -interface ExerciseWithDueDate { +export interface ExerciseWithDueDate { exercise: Exercise; dueDate?: dayjs.Dayjs; } @@ -83,6 +83,10 @@ export class CourseExercisesComponent implements OnInit, OnChanges, OnDestroy, A searchExercisesInput: string; exerciseFilter: ExerciseFilterModel; + filteredExercises: Exercise[] | undefined; + showExercisesGroupedByDueDateCategory: boolean = true; + lastAppliedSearchString: string | undefined; + // Icons faPlayCircle = faPlayCircle; faFilter = faFilter; @@ -204,6 +208,10 @@ export class CourseExercisesComponent implements OnInit, OnChanges, OnDestroy, A this.applyFiltersAndOrder(); } + toggleExerciseView() { + this.showExercisesGroupedByDueDateCategory = !this.showExercisesGroupedByDueDateCategory; + } + /** * Filters all displayed exercises by applying the selected activeFilters * @param filters The filters which should be applied @@ -223,6 +231,10 @@ export class CourseExercisesComponent implements OnInit, OnChanges, OnDestroy, A return !this.activeFilters.has(ExerciseFilter.UNRELEASED) || (exercise as QuizExercise)?.visibleToStudents; } + setShowExercisesGroupedByDueDateCategory(updatedShowExercisesGroupedByDueDateCategory: boolean) { + this.showExercisesGroupedByDueDateCategory = updatedShowExercisesGroupedByDueDateCategory; + } + /** * Method is called when enter key is pressed on search input or search button is clicked */ @@ -230,6 +242,7 @@ export class CourseExercisesComponent implements OnInit, OnChanges, OnDestroy, A this.searchExercisesInput = this.searchExercisesInput.trim(); this.exerciseFilter = new ExerciseFilterModel(this.searchExercisesInput); this.applyFiltersAndOrder(); + this.lastAppliedSearchString = this.exerciseFilter.exerciseNameSearch; } get canUnenroll(): boolean { @@ -259,6 +272,7 @@ export class CourseExercisesComponent implements OnInit, OnChanges, OnDestroy, A private applyFiltersAndOrder() { let filtered = this.course?.exercises?.filter(this.fulfillsCurrentFilter.bind(this)); filtered = filtered?.filter((exercise) => this.exerciseFilter.matchesExercise(exercise)); + this.filteredExercises = filtered; this.groupExercises(filtered); } diff --git a/src/main/webapp/app/overview/course-overview.scss b/src/main/webapp/app/overview/course-overview.scss index aac56b719f75..bbf53aac2730 100644 --- a/src/main/webapp/app/overview/course-overview.scss +++ b/src/main/webapp/app/overview/course-overview.scss @@ -94,6 +94,25 @@ Course Info Bar } } +.chevron-position { + display: inline-block; + vertical-align: middle; +} + +.rotate-icon { + transition: transform 0.3s ease; +} + +.rotated { + transform: rotate(90deg); +} + +.icon-container { + display: inline-block; + vertical-align: middle; + horiz-align: center; +} + .course-information { padding-top: 29px; diff --git a/src/main/webapp/app/shared/components/button.component.ts b/src/main/webapp/app/shared/components/button.component.ts index c9bcdce9bf8c..8038de9290a7 100644 --- a/src/main/webapp/app/shared/components/button.component.ts +++ b/src/main/webapp/app/shared/components/button.component.ts @@ -15,6 +15,8 @@ export enum ButtonType { WARNING = 'btn-warning', ERROR = 'btn-danger', INFO = 'btn-info', + + PRIMARY_OUTLINE = 'btn-outline-primary', } /** diff --git a/src/main/webapp/app/shared/components/clone-repo-button/clone-repo-button.component.ts b/src/main/webapp/app/shared/components/clone-repo-button/clone-repo-button.component.ts index fe9f00995b97..49a625df25a7 100644 --- a/src/main/webapp/app/shared/components/clone-repo-button/clone-repo-button.component.ts +++ b/src/main/webapp/app/shared/components/clone-repo-button/clone-repo-button.component.ts @@ -6,12 +6,12 @@ import { AccountService } from 'app/core/auth/account.service'; import { User } from 'app/core/user/user.model'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { LocalStorageService } from 'ngx-webstorage'; -import { faDownload, faExternalLink } from '@fortawesome/free-solid-svg-icons'; import { ProgrammingExerciseStudentParticipation } from 'app/entities/participation/programming-exercise-student-participation.model'; import { ParticipationService } from 'app/exercises/shared/participation/participation.service'; import { Exercise } from 'app/entities/exercise.model'; import { PROFILE_LOCALVC } from 'app/app.constants'; import { isPracticeMode } from 'app/entities/participation/student-participation.model'; +import { faDownload, faExternalLink } from '@fortawesome/free-solid-svg-icons'; @Component({ selector: 'jhi-clone-repo-button', diff --git a/src/main/webapp/app/shared/notification/notification-sidebar/notification-sidebar.component.html b/src/main/webapp/app/shared/notification/notification-sidebar/notification-sidebar.component.html index dfaf4a3272e1..0c660f62f366 100644 --- a/src/main/webapp/app/shared/notification/notification-sidebar/notification-sidebar.component.html +++ b/src/main/webapp/app/shared/notification/notification-sidebar/notification-sidebar.component.html @@ -9,7 +9,7 @@
-
+
diff --git a/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.html b/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.html index 0d33906e8777..b9f16b7260ef 100644 --- a/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.html +++ b/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.html @@ -1,57 +1,59 @@ - -
-
- - - -
-
- +
+ +
+
+ + + +
+
+ +
-
- + - - + + - -
-
- -
-
-
-

-
- -
- - -

- + +
+
+
-
- +
+
+

+ + + + + +

+ +
+
+ +
-
-
+ - - diff --git a/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.scss b/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.scss index 6f5db423951d..b49eef95708a 100644 --- a/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.scss +++ b/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.scss @@ -1,5 +1,4 @@ -:host { - display: flex; +.resizable-container { width: 100%; height: 100%; overflow: hidden; diff --git a/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.ts b/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.ts index 779de41b6b3a..02400ff7ba3c 100644 --- a/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.ts +++ b/src/main/webapp/app/shared/resizeable-container/resizeable-container.component.ts @@ -23,6 +23,20 @@ export class ResizeableContainerComponent implements AfterViewInit { @Input() collapsed = false; @Input() isExerciseParticipation = false; + /** + * Expected to be set to true while the component is printed as PDF. + * + * e.g. the case for printing the exam summary + */ + @Input() isBeingPrinted?: boolean = false; + + /** + * Forces the problem statement to be expanded when the component is printed as PDF + * + * e.g. the case for printing the exam summary + */ + @Input() expandProblemStatement?: boolean = false; + // Icons faChevronRight = faChevronRight; faChevronLeft = faChevronLeft; diff --git a/src/main/webapp/app/shared/util/utils.ts b/src/main/webapp/app/shared/util/utils.ts index 1471b15bf318..c1317b62ddb1 100644 --- a/src/main/webapp/app/shared/util/utils.ts +++ b/src/main/webapp/app/shared/util/utils.ts @@ -142,3 +142,7 @@ export class Range { return '[' + this.lowerBound + '%, ' + this.upperBound + '%' + (this.upperBound === 100 ? ']' : ')'); } } + +export function getAsMutableObject(object: any) { + return { ...object }; +} diff --git a/src/main/webapp/i18n/de/exam.json b/src/main/webapp/i18n/de/exam.json index fc0fed7ddb24..73ef51b079b7 100644 --- a/src/main/webapp/i18n/de/exam.json +++ b/src/main/webapp/i18n/de/exam.json @@ -62,15 +62,20 @@ } }, "examSummary": { + "backToOverview": "Zurück zur Übersicht", "examResults": "Prüfungsergebnisse", "generalInformation": "Allgemeine Informationen", "exportPDF": "Als PDF exportieren", "noSubmissionFound": "Du hast keine Lösung für diese Aufgabe abgegeben.", - "lastCommitHash": "Hash des letzten Commits", + "submissionLinkedToCommit": "Die abgegebene Lösung ist mit folgendem Commit verknüpft: ", "noCommitHash": "Es hat kein Commit stattgefunden", "studentSubmissionTo": "{{studentName}}s Abgabe für {{examTitle}}", - "viewResult": " Ergebnis ansehen", - "viewExampleSolution": "Beispiellösung anzeigen", + "showExampleSolution": "Beispiellösung anzeigen", + "hideExampleSolution": "Beispiellösung ausblenden", + "yourSubmission": "Deine Abgabe", + "problemStatement": "Problemstellung", + "assessment": "Bewertung", + "youAreViewingTheExampleSolution": "Du betrachtest die Beispiellösung", "missingResultNotice": "Es gibt derzeit kein Ergebnis für diese Quiz Aufgabe, obwohl die Ergebnisse schon veröffentlicht wurden. Bitte informiere die entsprechende Lehrkraft.", "points": { "exercise": "Aufgabe", @@ -126,7 +131,6 @@ "updated": "Klausur bearbeitet", "imported": "Klausur importiert", "deleted": "Klausur \"{{ param }}\" gelöscht", - "exercise": "Aufgabe {{nr}}", "startConsentText": "Hiermit bestätige ich mit meinem vollen Namen stellvertretend für meine Unterschrift, dass ich die Teilnahmebedingungen gelesen habe und diese für die Dauer der Klausur einhalten werde.", "endConsentText": "Hiermit bestätige ich mit meinem vollen Namen stellvertretend für meine Unterschrift, dass ich die Klausur eigenständig und nur mithilfe der angegebenen Hilfsmittel bearbeitet habe.", "notConfirmed": "Bitte setze einen Haken in das Kästchen, um fortzufahren.", @@ -151,7 +155,9 @@ "maxPointsNotSet": "Die maximale Punktzahl in der Klausur ist nicht gesetzt." }, "error": { - "cannotRegisterInstructor": "Du kannst keine Personen aus der Lehr- oder Administrationsgruppe zu Klausuren anmelden." + "cannotRegisterInstructor": "Du kannst keine Personen aus der Lehr- oder Administrationsgruppe zu Klausuren anmelden.", + "cannotShowExampleSolution": "Beispiellösung wurde nicht gefunden", + "cannotDisplayExerciseDetails": "Aufgabe {{ exerciseGroupTitle }} kann nicht angezeigt werden" }, "problemStatementUpdate": { "showDiff": "Zeige Unterschiede", diff --git a/src/main/webapp/i18n/de/student-dashboard.json b/src/main/webapp/i18n/de/student-dashboard.json index bc34950a6fe6..734e8589a0bd 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -56,12 +56,19 @@ "sortExercises": "Aufgaben sortieren", "newFirst": "Neueste", "oldFirst": "Älteste", + "past": "Vorangegangen", + "current": "Aktuell", + "future": "Zukünftig", + "noDueDate": "Unbefristet", + "view": "Ansicht", + "timeframeView": "Zeitliche Ansicht", + "weeklyView": "Wochenansicht", "noExercises": "Es gibt keine Aufgaben für diesen Kurs.", + "noExerciseMatchesSearchAndFilters": "Es wurden keine Aufgaben gefunden die mit den aktuellen Such- und Filtereinstellungen übereinstimmen.", "notReleasedTooltip": "Nicht sichtbar für Studierende. Veröffentlichungsdatum: ", "notReleased": "Nicht freigegeben", "live": "Live", "shortDueDate": "Bis", - "noDueDate": "Keine Einreichungsfrist", "userNotStartedExercise": "Du hast mit der Aufgabe noch nicht angefangen.", "exerciseSubmitted": "Bereits abgegeben, Änderungen sind noch möglich.", "exerciseSubmittedWaitingForGrading": "Bereits abgegeben, noch keine Bewertung.", diff --git a/src/main/webapp/i18n/en/exam.json b/src/main/webapp/i18n/en/exam.json index afb19bc9657c..0d0ad9daac6d 100644 --- a/src/main/webapp/i18n/en/exam.json +++ b/src/main/webapp/i18n/en/exam.json @@ -62,15 +62,20 @@ } }, "examSummary": { + "backToOverview": "Back to overview", "examResults": "Exam Results", "generalInformation": "General Information", "exportPDF": "Export PDF", "noSubmissionFound": "You didn't submit any solution for this exercise.", - "lastCommitHash": "Last Commit Hash", + "submissionLinkedToCommit": "The submission is linked to commit", "noCommitHash": "No commit was made", "studentSubmissionTo": "{{studentName}}'s submission to {{examTitle}}", - "viewResult": " View Result", - "viewExampleSolution": " View Example Solution", + "showExampleSolution": "Show Example Solution", + "hideExampleSolution": "Hide Example Solution", + "yourSubmission": "Your Submission", + "problemStatement": "Problem Statement", + "assessment": "Assessment", + "youAreViewingTheExampleSolution": "You are viewing the example solution", "missingResultNotice": "There is currently no result for this quiz exercise, although the results have already been published. Please inform your instructor.", "points": { "exercise": "Exercise", @@ -126,7 +131,6 @@ "updated": "Exam updated", "imported": "Exam imported", "deleted": "Deleted exam \"{{ param }}\"", - "exercise": "Exercise {{nr}}", "startConsentText": "I hereby confirm with my full name, representing my signature, that I have read the conditions of participation and will adhere to them for the duration of the exam.", "endConsentText": "I hereby confirm with my full name, representing my signature, that this exam is my own work and I have only used the indicated aids.", "notConfirmed": "Please tick the checkbox to continue.", @@ -151,7 +155,9 @@ "maxPointsNotSet": "The exam max points are not set." }, "error": { - "cannotRegisterInstructor": "You cannot register instructors or administrators for exams." + "cannotRegisterInstructor": "You cannot register instructors or administrators for exams.", + "cannotShowExampleSolution": "Example solution could not be found", + "cannotDisplayExerciseDetails": "Exercise {{ exerciseGroupTitle }} cannot be displayed properly" }, "problemStatementUpdate": { "showDiff": "Show Difference", diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index b0212682ffba..5197dd337770 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -56,12 +56,19 @@ "sortExercises": "Sort exercises", "newFirst": "Newest", "oldFirst": "Oldest", + "past": "Past", + "current": "Current", + "future": "Future", + "noDueDate": "No due date", + "view": "View", + "timeframeView": "Timeframe view", + "weeklyView": "Weekly view", "noExercises": "No exercises available for the course.", + "noExerciseMatchesSearchAndFilters": "No exercises found with the current search and filter settings.", "notReleasedTooltip": "Only visible to teaching assistants and instructors. Release Date: ", "notReleased": "Not released", "live": "Live", "shortDueDate": "Due", - "noDueDate": "No due date", "userNotAssignedToTeam": "You have not been assigned to a team yet.", "userNotStartedExercise": "You have not started this exercise yet.", "exerciseSubmitted": "Submitted, you can still submit updates.", diff --git a/src/test/cypress/e2e/exercises/ExerciseImport.cy.ts b/src/test/cypress/e2e/exercises/ExerciseImport.cy.ts index 2754e69f5b97..fa9987bc8a43 100644 --- a/src/test/cypress/e2e/exercises/ExerciseImport.cy.ts +++ b/src/test/cypress/e2e/exercises/ExerciseImport.cy.ts @@ -59,6 +59,40 @@ describe('Import exercises', () => { }); }); + after('Delete Courses', () => { + courseManagementAPIRequest.deleteCourse(course, admin); + courseManagementAPIRequest.deleteCourse(secondCourse, admin); + }); + + /** + * Needs to be executed first as the exercise has no due date => would be collapsed in overview if exercises with current due date are displayed + * + * FIXME + * a) fix the setDueDate method + * b) switch to the week view to display the exercise + */ + it('Imports programming exercise', () => { + cy.login(instructor, `/course-management/${secondCourse.id}/exercises`); + courseManagementExercises.importProgrammingExercise(); + courseManagementExercises.clickImportExercise(programmingExercise.id!); + + checkField('#field_points', programmingExercise.maxPoints!); + + programmingExerciseCreation.setTitle('Import Test'); + programmingExerciseCreation.setShortName('importtest' + generateUUID()); + // programmingExerciseCreation.setDueDate(dayjs().add(3, 'days')); // FIXME does not work yet + + programmingExerciseCreation.import().then((request: Interception) => { + const exercise = request.response!.body; + cy.login(studentOne, `/courses/${secondCourse.id}`); + courseOverview.startExercise(exercise.id!); + courseOverview.openRunningExercise(exercise.id!); + programmingExerciseEditor.makeSubmissionAndVerifyResults(exercise.id!, javaPartiallySuccessfulSubmission, () => { + programmingExerciseEditor.getResultScore().contains(javaPartiallySuccessfulSubmission.expectedResult).and('be.visible'); + }); + }); + }); + it('Imports text exercise', () => { cy.login(instructor, `/course-management/${secondCourse.id}/exercises`); courseManagementExercises.importTextExercise(); @@ -143,30 +177,4 @@ describe('Import exercises', () => { }); }); }); - - it('Imports programming exercise', () => { - cy.login(instructor, `/course-management/${secondCourse.id}/exercises`); - courseManagementExercises.importProgrammingExercise(); - courseManagementExercises.clickImportExercise(programmingExercise.id!); - - checkField('#field_points', programmingExercise.maxPoints!); - - programmingExerciseCreation.setTitle('Import Test'); - programmingExerciseCreation.setShortName('importtest' + generateUUID()); - - programmingExerciseCreation.import().then((request: Interception) => { - const exercise = request.response!.body; - cy.login(studentOne, `/courses/${secondCourse.id}`); - courseOverview.startExercise(exercise.id!); - courseOverview.openRunningExercise(exercise.id!); - programmingExerciseEditor.makeSubmissionAndVerifyResults(exercise.id!, javaPartiallySuccessfulSubmission, () => { - programmingExerciseEditor.getResultScore().contains(javaPartiallySuccessfulSubmission.expectedResult).and('be.visible'); - }); - }); - }); - - after('Delete Courses', () => { - courseManagementAPIRequest.deleteCourse(course, admin); - courseManagementAPIRequest.deleteCourse(secondCourse, admin); - }); }); diff --git a/src/test/cypress/support/pageobjects/exam/ExamManagementPage.ts b/src/test/cypress/support/pageobjects/exam/ExamManagementPage.ts index cfecaea87102..96ec66c7cb27 100644 --- a/src/test/cypress/support/pageobjects/exam/ExamManagementPage.ts +++ b/src/test/cypress/support/pageobjects/exam/ExamManagementPage.ts @@ -80,7 +80,7 @@ export class ExamManagementPage { cy.visit(`/course-management/${courseID}/exams/${examID}/student-exams`); cy.get('#student-exam').find('.datatable-body-row').filter(`:contains("${username}")`).find('.view-submission').click(); cy.get('.summery').click(); - cy.get('#result-score').contains(score); + cy.get('#exercise-result-score').contains(score); } clickEdit() { diff --git a/src/test/cypress/support/pageobjects/exam/ExamParticipation.ts b/src/test/cypress/support/pageobjects/exam/ExamParticipation.ts index c1216510c934..28473ef75a9c 100644 --- a/src/test/cypress/support/pageobjects/exam/ExamParticipation.ts +++ b/src/test/cypress/support/pageobjects/exam/ExamParticipation.ts @@ -104,8 +104,8 @@ export class ExamParticipation { } getResultScore() { - cy.reloadUntilFound('#result-score'); - return cy.get('#result-score'); + cy.reloadUntilFound('#exercise-result-score'); + return cy.get('#exercise-result-score'); } checkExamFinishedTitle(title: string) { @@ -128,12 +128,12 @@ export class ExamParticipation { } verifyExerciseTitleOnFinalPage(exerciseID: number, exerciseTitle: string) { - getExercise(exerciseID).find('.exercise-title').contains(exerciseTitle).should('be.visible'); + getExercise(exerciseID).find(`#exercise-group-title-${exerciseID}`).contains(exerciseTitle).should('be.visible'); } verifyTextExerciseOnFinalPage(textFixture: string) { cy.fixture(textFixture).then((submissionText) => { - cy.contains(submissionText).should('be.visible'); + cy.get('textarea').should('have.value', submissionText); }); } } diff --git a/src/test/cypress/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts b/src/test/cypress/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts index dc07db838548..4ac3dc83bdcd 100644 --- a/src/test/cypress/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts +++ b/src/test/cypress/support/pageobjects/exercises/programming/ProgrammingExerciseCreationPage.ts @@ -1,4 +1,7 @@ import { BASE_API, POST, PROGRAMMING_EXERCISE_BASE, ProgrammingLanguage } from '../../../constants'; +import { Dayjs } from 'dayjs/esm'; + +const OWL_DATEPICKER_ARIA_LABEL_DATE_FORMAT = 'MMMM D, YYYY'; /** * A class which encapsulates UI selectors and actions for the programming exercise creation page. @@ -53,14 +56,30 @@ export class ProgrammingExerciseCreationPage { generate() { cy.intercept(POST, PROGRAMMING_EXERCISE_BASE + 'setup').as('createProgrammingExercise'); cy.get('#save-entity').click(); - // Creating a programming exercise can take quite a while so we increase the default timeout here + // Creating a programming exercise can take quite a while, so we increase the default timeout here return cy.wait('@createProgrammingExercise', { timeout: 60000 }); } import() { cy.intercept(POST, BASE_API + 'programming-exercises/import/*').as('programmingExerciseImport'); cy.get('#save-entity').click(); - // Creating a programming exercise can take quite a while so we increase the default timeout here + // Creating a programming exercise can take quite a while, so we increase the default timeout here return cy.wait('@programmingExerciseImport', { timeout: 60000 }); } + + /** + * Sets the Due Date field + * @param date + * */ + setDueDate(date: Dayjs) { + cy.get('#programming-exercise-due-date-picker').click(); + + const ariaLabelDate = date.format(OWL_DATEPICKER_ARIA_LABEL_DATE_FORMAT); + cy.get(`td[aria-label="${ariaLabelDate}"]`).click(); + + cy.get('.owl-dt-control-content.owl-dt-control-button-content').contains('Set').should('exist').click(); + + // FIXME for an yet unknown reason the set button appears to be clicked, the due date is set, + // but the owl datepicker overlay seems to persist, did not find a way to close it => import button cannot be clicked + } } diff --git a/src/test/javascript/spec/component/course/course-exercises.component.spec.ts b/src/test/javascript/spec/component/course/course-exercises.component.spec.ts index 0d8e35338edb..076a24c7170a 100644 --- a/src/test/javascript/spec/component/course/course-exercises.component.spec.ts +++ b/src/test/javascript/spec/component/course/course-exercises.component.spec.ts @@ -33,6 +33,8 @@ import { CourseStorageService } from 'app/course/manage/course-storage.service'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { CourseUnenrollmentModalComponent } from 'app/overview/course-unenrollment-modal.component'; import { MockNgbModalService } from '../../helpers/mocks/service/mock-ngb-modal.service'; +import { CourseExercisesGroupedByWeekComponent } from 'app/overview/course-exercises/course-exercises-grouped-by-week.component'; +import { CourseExercisesGroupedByCategoryComponent } from 'app/overview/course-exercises/course-exercises-grouped-by-category.component'; describe('CourseExercisesComponent', () => { let fixture: ComponentFixture; @@ -59,6 +61,8 @@ describe('CourseExercisesComponent', () => { MockDirective(OrionFilterDirective), MockComponent(CourseExerciseRowComponent), MockComponent(SidePanelComponent), + MockComponent(CourseExercisesGroupedByWeekComponent), + MockComponent(CourseExercisesGroupedByCategoryComponent), MockDirective(MockHasAnyAuthorityDirective), MockDirective(SortByDirective), TranslatePipeMock, diff --git a/src/test/javascript/spec/component/exam/exam-update.component.spec.ts b/src/test/javascript/spec/component/exam/exam-update.component.spec.ts index 9afac8f2e64d..ebcd1cfb24b2 100644 --- a/src/test/javascript/spec/component/exam/exam-update.component.spec.ts +++ b/src/test/javascript/spec/component/exam/exam-update.component.spec.ts @@ -48,7 +48,7 @@ import { TitleChannelNameComponent } from 'app/shared/form/title-channel-name/ti }) class DummyComponent {} -describe('Exam Update Component', () => { +describe('ExamUpdateComponent', () => { let component: ExamUpdateComponent; let fixture: ComponentFixture; let examManagementService: ExamManagementService; diff --git a/src/test/javascript/spec/component/exam/feedback.utils.spec.ts b/src/test/javascript/spec/component/exam/feedback.utils.spec.ts new file mode 100644 index 000000000000..adf848356db6 --- /dev/null +++ b/src/test/javascript/spec/component/exam/feedback.utils.spec.ts @@ -0,0 +1,53 @@ +import { prepareFeedbackComponentParameters } from 'app/exercises/shared/feedback/feedback.utils'; +import { ResultTemplateStatus } from 'app/exercises/shared/result/result.utils'; +import { AssessmentType } from 'app/entities/assessment-type.model'; +import { ExerciseType } from 'app/entities/exercise.model'; +import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; +import dayjs from 'dayjs/esm'; +import { of } from 'rxjs'; +import { MockProvider } from 'ng-mocks'; +import { TestBed } from '@angular/core/testing'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; + +describe('FeedbackUtils', () => { + let exerciseService: ExerciseService; + + beforeEach(() => { + return TestBed.configureTestingModule({ + providers: [MockProvider(ExerciseService)], + }) + .compileComponents() + .then(() => { + exerciseService = TestBed.inject(ExerciseService); + }); + }); + + describe('prepareFeedbackComponentParameters', () => { + const exercise = { id: 42, type: ExerciseType.PROGRAMMING, dueDate: dayjs().subtract(4, 'hours') } as ProgrammingExercise; + const result = { assessmentType: AssessmentType.AUTOMATIC }; + const participation = {}; + const templateStatus = ResultTemplateStatus.MISSING; + const latestDueDate = dayjs().subtract(4, 'hours'); + + it('should determine automatic feedback information with latestDueDate being passed', () => { + const preparedParameters = prepareFeedbackComponentParameters(exercise, result, participation, templateStatus, latestDueDate, exerciseService); + + expect(preparedParameters.showScoreChart).toBeTrue(); + expect(preparedParameters.messageKey).toBe('artemisApp.result.notLatestSubmission'); + expect(preparedParameters.showMissingAutomaticFeedbackInformation).toBeFalse(); + }); + + it('should determine automatic feedback information if latestDueDate is passed as undefined', () => { + const exerciseServiceLatestDueDate = dayjs().add(4, 'hours'); + const getLatestDueDateSpy = jest.spyOn(exerciseService, 'getLatestDueDate').mockReturnValue(of(exerciseServiceLatestDueDate)); + + const preparedParameters = prepareFeedbackComponentParameters(exercise, result, participation, ResultTemplateStatus.HAS_RESULT, undefined, exerciseService); + + expect(getLatestDueDateSpy).toHaveBeenCalledOnce(); + expect(preparedParameters.showScoreChart).toBeTrue(); + expect(preparedParameters.messageKey).not.toBeTruthy(); + expect(preparedParameters.latestDueDate).toEqual(exerciseServiceLatestDueDate); + expect(preparedParameters.showMissingAutomaticFeedbackInformation).toBeTrue(); + }); + }); +}); diff --git a/src/test/javascript/spec/component/exam/participate/summary/exam-participation-summary.component.spec.ts b/src/test/javascript/spec/component/exam/participate/summary/exam-result-summary.component.spec.ts similarity index 70% rename from src/test/javascript/spec/component/exam/participate/summary/exam-participation-summary.component.spec.ts rename to src/test/javascript/spec/component/exam/participate/summary/exam-result-summary.component.spec.ts index 56c40745a6c5..c4ac93c7be55 100644 --- a/src/test/javascript/spec/component/exam/participate/summary/exam-participation-summary.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/summary/exam-result-summary.component.spec.ts @@ -21,14 +21,13 @@ import { ProgrammingSubmission } from 'app/entities/programming-submission.model import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model'; import { QuizSubmission } from 'app/entities/quiz/quiz-submission.model'; import { StudentExam } from 'app/entities/student-exam.model'; -import { SubmissionType } from 'app/entities/submission.model'; import { TextExercise } from 'app/entities/text-exercise.model'; import { TextSubmission } from 'app/entities/text-submission.model'; -import { StudentExamWithGradeDTO, StudentResult } from 'app/exam/exam-scores/exam-score-dtos.model'; +import { ExerciseResult, StudentExamWithGradeDTO, StudentResult } from 'app/exam/exam-scores/exam-score-dtos.model'; import { TestRunRibbonComponent } from 'app/exam/manage/test-runs/test-run-ribbon.component'; import { ExamParticipationService } from 'app/exam/participate/exam-participation.service'; import { ExamGeneralInformationComponent } from 'app/exam/participate/general-information/exam-general-information.component'; -import { ExamResultSummaryComponent } from 'app/exam/participate/summary/exam-result-summary.component'; +import { ExamResultSummaryComponent, ResultSummaryExerciseInfo } from 'app/exam/participate/summary/exam-result-summary.component'; import { FileUploadExamSummaryComponent } from 'app/exam/participate/summary/exercises/file-upload-exam-summary/file-upload-exam-summary.component'; import { ModelingExamSummaryComponent } from 'app/exam/participate/summary/exercises/modeling-exam-summary/modeling-exam-summary.component'; import { ProgrammingExamSummaryComponent } from 'app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component'; @@ -51,6 +50,10 @@ import { NgbCollapseMocksModule } from '../../../../helpers/mocks/directive/ngbC import { MockExamParticipationService } from '../../../../helpers/mocks/service/mock-exam-participation.service'; import { MockLocalStorageService } from '../../../../helpers/mocks/service/mock-local-storage.service'; import { MockArtemisServerDateService } from '../../../../helpers/mocks/service/mock-server-date.service'; +import { ExamResultSummaryExerciseCardHeaderComponent } from 'app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component'; +import { Course } from 'app/entities/course.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { ProgrammingExerciseExampleSolutionRepoDownloadComponent } from 'app/exercises/programming/shared/actions/programming-exercise-example-solution-repo-download.component'; let fixture: ComponentFixture; let component: ExamResultSummaryComponent; @@ -65,6 +68,8 @@ const publishResultsDate = dayjs().subtract(3, 'hours'); const examStudentReviewStart = dayjs().subtract(2, 'hours'); const examStudentReviewEnd = dayjs().add(1, 'hours'); +const course = { id: 1, accuracyOfScores: 2 } as Course; + const exam = { id: 1, title: 'ExamForTesting', @@ -75,6 +80,7 @@ const exam = { examStudentReviewStart, examStudentReviewEnd, testExam: false, + course, } as Exam; const testExam = { @@ -84,6 +90,7 @@ const testExam = { startDate, endDate, testExam: true, + course, } as Exam; const exerciseGroup = { @@ -120,6 +127,9 @@ const studentExamForTestExam = { user, exercises, } as StudentExam; + +const textExerciseResult = { exerciseId: textExercise.id, achievedScore: 60, achievedPoints: 6, maxScore: textExercise.maxPoints } as ExerciseResult; + const gradeInfo: StudentExamWithGradeDTO = { maxPoints: 100, maxBonusPoints: 10, @@ -150,10 +160,12 @@ function sharedSetup(url: string[]) { MockComponent(FileUploadExamSummaryComponent), MockComponent(ComplaintsStudentViewComponent), MockComponent(FaIconComponent), + MockComponent(ExamResultSummaryExerciseCardHeaderComponent), MockDirective(TranslateDirective), MockPipe(ArtemisTranslatePipe), MockPipe(HtmlForMarkdownPipe), MockComponent(IncludedInScoreBadgeComponent), + MockComponent(ProgrammingExerciseExampleSolutionRepoDownloadComponent), ], providers: [ { @@ -175,6 +187,7 @@ function sharedSetup(url: string[]) { { provide: LocalStorageService, useClass: MockLocalStorageService }, { provide: ArtemisServerDateService, useClass: MockArtemisServerDateService }, { provide: ExamParticipationService, useClass: MockExamParticipationService }, + MockProvider(AlertService), ], }) .compileComponents() @@ -195,27 +208,24 @@ describe('ExamResultSummaryComponent', () => { sharedSetup(['', '']); it('should expand all exercises and call print when Export PDF is clicked', fakeAsync(() => { - const printStub = jest.spyOn(TestBed.inject(ThemeService), 'print').mockReturnValue(); + const printStub = jest.spyOn(TestBed.inject(ThemeService), 'print').mockResolvedValue(undefined); fixture.detectChanges(); const exportToPDFButton = fixture.debugElement.query(By.css('#exportToPDFButton')); - const toggleCollapseExerciseButtonOne = fixture.debugElement.query(By.css('#toggleCollapseExerciseButton-0')); - const toggleCollapseExerciseButtonTwo = fixture.debugElement.query(By.css('#toggleCollapseExerciseButton-1')); - const toggleCollapseExerciseButtonThree = fixture.debugElement.query(By.css('#toggleCollapseExerciseButton-2')); - const toggleCollapseExerciseButtonFour = fixture.debugElement.query(By.css('#toggleCollapseExerciseButton-3')); + expect(exportToPDFButton).not.toBeNull(); - expect(toggleCollapseExerciseButtonOne).not.toBeNull(); - expect(toggleCollapseExerciseButtonTwo).not.toBeNull(); - expect(toggleCollapseExerciseButtonThree).not.toBeNull(); - expect(toggleCollapseExerciseButtonFour).not.toBeNull(); - toggleCollapseExerciseButtonOne.nativeElement.click(); - toggleCollapseExerciseButtonTwo.nativeElement.click(); - toggleCollapseExerciseButtonThree.nativeElement.click(); - toggleCollapseExerciseButtonFour.nativeElement.click(); - expect(component.collapsedExerciseIds).toHaveLength(4); + component.exerciseInfos[1].isCollapsed = true; + component.exerciseInfos[2].isCollapsed = true; + component.exerciseInfos[3].isCollapsed = true; + component.exerciseInfos[4].isCollapsed = true; exportToPDFButton.nativeElement.click(); - expect(component.collapsedExerciseIds).toBeEmpty(); + + expect(component.exerciseInfos[1].isCollapsed).toBeFalse(); + expect(component.exerciseInfos[2].isCollapsed).toBeFalse(); + expect(component.exerciseInfos[3].isCollapsed).toBeFalse(); + expect(component.exerciseInfos[4].isCollapsed).toBeFalse(); + tick(); expect(printStub).toHaveBeenCalledOnce(); printStub.mockRestore(); @@ -277,27 +287,6 @@ describe('ExamResultSummaryComponent', () => { expect(submission).toEqual(expectedResult); }); - it.each([ - [{}, false], - [{ studentParticipations: null }, false], - [{ studentParticipations: undefined }, false], - [{ studentParticipations: [] }, false], - [{ studentParticipations: [{}] }, false], - [{ studentParticipations: [{ submissions: null }] }, false], - [{ studentParticipations: [{ submissions: undefined }] }, false], - [{ studentParticipations: [{ submissions: [{ type: SubmissionType.MANUAL }] }] }, false], - [{ studentParticipations: [{ submissions: [{ type: SubmissionType.ILLEGAL }] }] }, true], - ])('should handle missing/empty fields correctly for %o when displaying illegal submission badge', (exercise, shouldBeNonNull) => { - component.studentExam = { id: 1, exam, user, exercises: [exercise as Exercise], numberOfExamSessions: 0 }; - fixture.detectChanges(); - const span = fixture.debugElement.query(By.css('.badge.bg-danger')); - if (shouldBeNonNull) { - expect(span).not.toBeNull(); - } else { - expect(span).toBeNull(); - } - }); - it('should update student exam correctly', () => { const plagiarismService = fixture.debugElement.injector.get(PlagiarismCasesService); const plagiarismServiceSpy = jest.spyOn(plagiarismService, 'getPlagiarismCaseInfosForStudent'); @@ -360,26 +349,26 @@ describe('ExamResultSummaryComponent', () => { it('should correctly determine if the results are published', () => { component.studentExam = studentExam; component.testRunConduction = true; - expect(component.resultsPublished).toBeFalse(); + expect(component.resultsArePublished).toBeFalse(); component.testExamConduction = true; component.testRunConduction = false; - expect(component.resultsPublished).toBeFalse(); + expect(component.resultsArePublished).toBeFalse(); component.isTestRun = true; component.testExamConduction = false; - expect(component.resultsPublished).toBeTrue(); + expect(component.resultsArePublished).toBeTrue(); component.isTestExam = true; component.isTestRun = false; - expect(component.resultsPublished).toBeTrue(); + expect(component.resultsArePublished).toBeTrue(); component.isTestExam = false; // const publishResultsDate is in the past - expect(component.resultsPublished).toBeTrue(); + expect(component.resultsArePublished).toBeTrue(); component.studentExam.exam!.publishResultsDate = dayjs().add(2, 'hours'); - expect(component.resultsPublished).toBeFalse(); + expect(component.resultsArePublished).toBeFalse(); }); it('should correctly determine if it is after student review start', () => { @@ -387,21 +376,25 @@ describe('ExamResultSummaryComponent', () => { const dateSpy = jest.spyOn(artemisServerDateService, 'now').mockReturnValue(now); component.isTestExam = true; - expect(component.isAfterStudentReviewStart()).toBeTrue(); + component.ngOnInit(); + expect(component.isAfterStudentReviewStart).toBeTrue(); component.isTestExam = false; component.isTestRun = true; - expect(component.isAfterStudentReviewStart()).toBeTrue(); + component.ngOnInit(); + expect(component.isAfterStudentReviewStart).toBeTrue(); component.isTestRun = false; component.studentExam.exam!.examStudentReviewStart = examStudentReviewStart; component.studentExam.exam!.examStudentReviewEnd = examStudentReviewEnd; - expect(component.isAfterStudentReviewStart()).toBeTrue(); + component.ngOnInit(); + expect(component.isAfterStudentReviewStart).toBeTrue(); component.studentExam.exam!.examStudentReviewStart = dayjs().add(30, 'minutes'); - expect(component.isAfterStudentReviewStart()).toBeFalse(); + component.ngOnInit(); + expect(component.isAfterStudentReviewStart).toBeFalse(); - expect(dateSpy).toHaveBeenCalledTimes(2); + expect(dateSpy).toHaveBeenCalled(); }); it('should correctly determine if it is before student review end', () => { @@ -409,25 +402,129 @@ describe('ExamResultSummaryComponent', () => { const dateSpy = jest.spyOn(artemisServerDateService, 'now').mockReturnValue(now); component.isTestExam = true; - expect(component.isBeforeStudentReviewEnd()).toBeTrue(); + component.ngOnInit(); + expect(component.isBeforeStudentReviewEnd).toBeTrue(); component.isTestExam = false; component.isTestRun = true; - expect(component.isBeforeStudentReviewEnd()).toBeTrue(); + component.ngOnInit(); + expect(component.isBeforeStudentReviewEnd).toBeTrue(); component.isTestRun = false; component.studentExam.exam!.examStudentReviewEnd = examStudentReviewEnd; - expect(component.isBeforeStudentReviewEnd()).toBeTrue(); + component.ngOnInit(); + expect(component.isBeforeStudentReviewEnd).toBeTrue(); component.studentExam.exam!.examStudentReviewEnd = dayjs().subtract(30, 'minutes'); - expect(component.isBeforeStudentReviewEnd()).toBeFalse(); + component.ngOnInit(); + expect(component.isBeforeStudentReviewEnd).toBeFalse(); - expect(dateSpy).toHaveBeenCalledTimes(2); + expect(dateSpy).toHaveBeenCalled(); }); - it('should show exercise group title', () => { - fixture.detectChanges(); - const exerciseTitleElement: HTMLElement = fixture.nativeElement.querySelector('.exercise-title'); - expect(exerciseTitleElement.textContent).toContain('exercise group'); + describe('getAchievedPercentageByExerciseId', () => { + beforeEach(() => { + const studentExam = { + exam: { + course, + }, + } as StudentExam; + + const studentResult = { + exerciseGroupIdToExerciseResult: { + [textExercise.id!]: textExerciseResult, + }, + } as StudentResult; + + component.studentExamGradeInfoDTO = { ...gradeInfo, studentExam, studentResult }; + }); + + it('should return undefined if exercise result is undefined', () => { + component.studentExamGradeInfoDTO.studentResult.exerciseGroupIdToExerciseResult = {}; + const scoreAsPercentage = component.getAchievedPercentageByExerciseId(textExercise.id); + + expect(scoreAsPercentage).toBeUndefined(); + }); + + it('should calculate percentage based on achievedScore considering course settings', () => { + textExerciseResult.achievedScore = 60.6666; + + const scoreAsPercentage = component.getAchievedPercentageByExerciseId(textExercise.id); + + expect(scoreAsPercentage).toBe(60.67); + }); + + it('should calculate percentage based on maxScore and achievedPoints', () => { + textExerciseResult.achievedScore = undefined; + textExerciseResult.maxScore = 10; + textExerciseResult.achievedPoints = 6.066666; + component.studentExamGradeInfoDTO.studentExam!.exam!.course!.accuracyOfScores = 3; + + const scoreAsPercentage = component.getAchievedPercentageByExerciseId(textExercise.id); + + expect(scoreAsPercentage).toBe(60.667); + }); + + it('should return undefined if not set and not calculable', () => { + textExerciseResult.achievedScore = undefined; + textExerciseResult.achievedPoints = undefined; + + const scoreAsPercentage = component.getAchievedPercentageByExerciseId(textExercise.id); + + expect(scoreAsPercentage).toBeUndefined(); + }); + }); + + describe('scrollToOverviewOrTop', () => { + const BACK_TO_OVERVIEW_BUTTON_ID = 'back-to-overview-button'; + const EXAM_SUMMARY_RESULT_OVERVIEW_ID = 'exam-summary-result-overview'; + + it('should scroll to top when overview is not displayed', () => { + const scrollToSpy = jest.spyOn(window, 'scrollTo'); + + const button = fixture.debugElement.nativeElement.querySelector('#' + BACK_TO_OVERVIEW_BUTTON_ID); + button.click(); + + expect(scrollToSpy).toHaveBeenCalledWith(0, 0); + }); + + it('should scroll to overview when it is displayed', () => { + const scrollToSpy = jest.spyOn(window, 'scrollTo'); + const scrollIntoViewSpy = jest.fn(); + + const getElementByIdMock = jest.spyOn(document, 'getElementById').mockReturnValue({ + scrollIntoView: scrollIntoViewSpy, + } as unknown as HTMLElement); + + component.studentExam = studentExam; + component.studentExamGradeInfoDTO = { ...gradeInfo, studentExam }; + + fixture.detectChanges(); + + const button = fixture.debugElement.nativeElement.querySelector('#' + BACK_TO_OVERVIEW_BUTTON_ID); + button.click(); + + expect(getElementByIdMock).toHaveBeenCalledWith(EXAM_SUMMARY_RESULT_OVERVIEW_ID); + expect(scrollIntoViewSpy).toHaveBeenCalled(); + expect(scrollToSpy).not.toHaveBeenCalled(); + }); + }); + + describe('toggleShowSampleSolution', () => { + it('should be called on button click', () => { + component.exerciseInfos = { + 1: { isCollapsed: false, displayExampleSolution: true } as ResultSummaryExerciseInfo, + }; + exam.exampleSolutionPublicationDate = dayjs().subtract(1, 'hour'); + const toggleShowSampleSolutionSpy = jest.spyOn(component, 'toggleShowSampleSolution'); + + fixture.detectChanges(); + + const button = fixture.debugElement.nativeElement.querySelector(`#show-sample-solution-button-${textExercise.id}`); + expect(button).toBeTruthy(); + + button.click(); + expect(toggleShowSampleSolutionSpy).toHaveBeenCalled(); + }); }); }); diff --git a/src/test/javascript/spec/component/exam/participate/summary/exercises/file-upload-summary.component.spec.ts b/src/test/javascript/spec/component/exam/participate/summary/exercises/file-upload-exam-summary.component.spec.ts similarity index 53% rename from src/test/javascript/spec/component/exam/participate/summary/exercises/file-upload-summary.component.spec.ts rename to src/test/javascript/spec/component/exam/participate/summary/exercises/file-upload-exam-summary.component.spec.ts index 0126e44f83ed..37611dc202f5 100644 --- a/src/test/javascript/spec/component/exam/participate/summary/exercises/file-upload-summary.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/summary/exercises/file-upload-exam-summary.component.spec.ts @@ -1,48 +1,53 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FileUploadExamSummaryComponent } from 'app/exam/participate/summary/exercises/file-upload-exam-summary/file-upload-exam-summary.component'; -import { MockPipe, MockProvider } from 'ng-mocks'; +import { MockComponent, MockPipe } from 'ng-mocks'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { FileService } from 'app/shared/http/file.service'; import { FileUploadSubmission } from 'app/entities/file-upload-submission.model'; import { By } from '@angular/platform-browser'; +import { FileUploadSubmissionComponent } from 'app/exercises/file-upload/participate/file-upload-submission.component'; +import { FileUploadExercise } from 'app/entities/file-upload-exercise.model'; describe('FileUploadExamSummaryComponent', () => { let fixture: ComponentFixture; let component: FileUploadExamSummaryComponent; - let fileService: FileService; const fileUploadSubmission = { id: 1 } as FileUploadSubmission; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], - declarations: [FileUploadExamSummaryComponent, MockPipe(ArtemisTranslatePipe)], - providers: [MockProvider(FileService)], + declarations: [FileUploadExamSummaryComponent, MockPipe(ArtemisTranslatePipe), MockComponent(FileUploadSubmissionComponent)], }) .compileComponents() .then(() => { fixture = TestBed.createComponent(FileUploadExamSummaryComponent); component = fixture.componentInstance; component.submission = fileUploadSubmission; - fileService = TestBed.inject(FileService); }); }); it('should initialize', () => { fixture.detectChanges(); expect(component).not.toBeNull(); - expect(component.attachmentExtension(component.submission.filePath!)).toBe('N/A'); }); - it('should correctly display the filepath', () => { - component.submission.filePath = 'filePath.pdf'; - const downloadFileSpy = jest.spyOn(fileService, 'downloadFile'); + it('should render submission when exercise and submisssion is set', () => { + const exercise = { id: 1234, studentParticipations: [{ id: 1 }] } as FileUploadExercise; + const submission = { submitted: true, filePath: 'filePath.pdf' } as FileUploadSubmission; + component.submission = submission; + component.exercise = exercise; + + fixture.detectChanges(); + + const fileUploadSubmissionComponent = fixture.debugElement.query(By.directive(FileUploadSubmissionComponent)).componentInstance; + expect(fileUploadSubmissionComponent).toBeTruthy(); + }); + + it('should not render submission if exercise and submission are not set', () => { fixture.detectChanges(); - expect(component.attachmentExtension(component.submission.filePath!)).toBe('pdf'); - const downloadFile = fixture.debugElement.query(By.css('#downloadFileButton')); - expect(downloadFile).not.toBeNull(); - downloadFile.nativeElement.click(); - expect(downloadFileSpy).toHaveBeenCalledOnce(); + + const fileUploadSubmissionComponent = fixture.debugElement.query(By.directive(FileUploadSubmissionComponent))?.componentInstance; + expect(fileUploadSubmissionComponent).not.toBeTruthy(); }); }); diff --git a/src/test/javascript/spec/component/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.spec.ts b/src/test/javascript/spec/component/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.spec.ts new file mode 100644 index 000000000000..12b0b9310ae5 --- /dev/null +++ b/src/test/javascript/spec/component/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component.spec.ts @@ -0,0 +1,102 @@ +import { ComponentFixture, TestBed, fakeAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { User } from 'app/core/user/user.model'; +import { Exam } from 'app/entities/exam.model'; +import { ExerciseGroup } from 'app/entities/exercise-group.model'; +import { Exercise, ExerciseType } from 'app/entities/exercise.model'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; +import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; +import { ProgrammingSubmission } from 'app/entities/programming-submission.model'; +import { SubmissionType } from 'app/entities/submission.model'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; +import { ExamResultSummaryExerciseCardHeaderComponent } from 'app/exam/participate/summary/exercises/header/exam-result-summary-exercise-card-header.component'; +import { ResultSummaryExerciseInfo } from 'app/exam/participate/summary/exam-result-summary.component'; + +let fixture: ComponentFixture; +let component: ExamResultSummaryExerciseCardHeaderComponent; + +const user = { id: 1, name: 'Test User' } as User; + +const exam = { + id: 1, + title: 'ExamForTesting', +} as Exam; + +const exerciseGroup = { + exam, + title: 'exercise group', +} as ExerciseGroup; + +const programmingSubmission = { id: 1 } as ProgrammingSubmission; + +const programmingParticipation = { id: 4, student: user, submissions: [programmingSubmission] } as StudentParticipation; + +const programmingExercise = { id: 4, type: ExerciseType.PROGRAMMING, studentParticipations: [programmingParticipation], exerciseGroup } as ProgrammingExercise; + +describe('ExamResultSummaryExerciseCardHeaderComponent', () => { + beforeEach(() => { + return TestBed.configureTestingModule({ + declarations: [ExamResultSummaryExerciseCardHeaderComponent, MockComponent(FaIconComponent), MockDirective(TranslateDirective), MockPipe(ArtemisTranslatePipe)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(ExamResultSummaryExerciseCardHeaderComponent); + component = fixture.componentInstance; + component.index = 3; + component.exercise = programmingExercise; + component.exerciseInfo = { isCollapsed: false } as ResultSummaryExerciseInfo; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should collapse and expand exercise when collapse button is clicked', fakeAsync(() => { + fixture.detectChanges(); + const toggleCollapseExerciseButtonFour = fixture.debugElement.query(By.css('#toggleCollapseExerciseButton-4')); + + expect(toggleCollapseExerciseButtonFour).not.toBeNull(); + + toggleCollapseExerciseButtonFour.nativeElement.click(); + + expect(component.exerciseInfo?.isCollapsed).toBeTrue(); + + toggleCollapseExerciseButtonFour.nativeElement.click(); + + expect(component.exerciseInfo?.isCollapsed).toBeFalse(); + })); + + it.each([ + [{}, false], + [{ studentParticipations: null }, false], + [{ studentParticipations: undefined }, false], + [{ studentParticipations: [] }, false], + [{ studentParticipations: [{}] }, false], + [{ studentParticipations: [{ submissions: null }] }, false], + [{ studentParticipations: [{ submissions: undefined }] }, false], + [{ studentParticipations: [{ submissions: [{ type: SubmissionType.MANUAL }] }] }, false], + [{ studentParticipations: [{ submissions: [{ type: SubmissionType.ILLEGAL }] }] }, true], + ])('should handle missing/empty fields correctly for %o when displaying illegal submission badge', (exercise, shouldBeNonNull) => { + component.exercise = exercise as Exercise; + + fixture.detectChanges(); + const span = fixture.debugElement.query(By.css('.badge.bg-danger')); + if (shouldBeNonNull) { + expect(span).not.toBeNull(); + } else { + expect(span).toBeNull(); + } + }); + + it('should show exercise group title', () => { + fixture.detectChanges(); + + const exerciseTitleElement: HTMLElement = fixture.nativeElement.querySelector('#exercise-group-title-' + programmingExercise.id); + expect(exerciseTitleElement.textContent).toContain('#' + (component.index + 1)); + expect(exerciseTitleElement.textContent).toContain(programmingExercise.exerciseGroup?.title); + }); +}); diff --git a/src/test/javascript/spec/component/exam/participate/summary/exercises/modeling-exam-summary.component.spec.ts b/src/test/javascript/spec/component/exam/participate/summary/exercises/modeling-exam-summary.component.spec.ts index a4ff6e71b58a..580c7a9b3aba 100644 --- a/src/test/javascript/spec/component/exam/participate/summary/exercises/modeling-exam-summary.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/summary/exercises/modeling-exam-summary.component.spec.ts @@ -4,23 +4,23 @@ import { Course } from 'app/entities/course.model'; import { ModelingExercise, UMLDiagramType } from 'app/entities/modeling-exercise.model'; import { ModelingSubmission } from 'app/entities/modeling-submission.model'; import { ModelingExamSummaryComponent } from 'app/exam/participate/summary/exercises/modeling-exam-summary/modeling-exam-summary.component'; -import { ModelingEditorComponent } from 'app/exercises/modeling/shared/modeling-editor.component'; import { MockComponent } from 'ng-mocks'; +import { ModelingSubmissionComponent } from 'app/exercises/modeling/participate/modeling-submission.component'; describe('ModelingExamSummaryComponent', () => { let fixture: ComponentFixture; - let comp: ModelingExamSummaryComponent; + let component: ModelingExamSummaryComponent; beforeEach(() => { TestBed.configureTestingModule({ imports: [], - declarations: [ModelingExamSummaryComponent, MockComponent(ModelingEditorComponent)], + declarations: [ModelingExamSummaryComponent, MockComponent(ModelingSubmissionComponent)], schemas: [], }) .compileComponents() .then(() => { fixture = TestBed.createComponent(ModelingExamSummaryComponent); - comp = fixture.componentInstance; + component = fixture.componentInstance; }); }); @@ -33,24 +33,28 @@ describe('ModelingExamSummaryComponent', () => { fixture.detectChanges(); const el = fixture.debugElement.query((de) => de.nativeElement.textContent === 'No submission'); expect(el).not.toBeNull(); - const modelingEditor = fixture.debugElement.query(By.directive(ModelingEditorComponent)); + const modelingEditor = fixture.debugElement.query(By.directive(ModelingSubmissionComponent)); expect(modelingEditor).toBeNull(); }); - it('should show modeling editor with correct props when there is submission and exercise', () => { + it('should show modeling submission when there is submission and exercise', () => { const mockSubmission = { explanationText: 'Test Explanation', model: JSON.stringify({ model: true }) } as ModelingSubmission; const course = new Course(); + const exercise = { course: course, exerciseGroup: undefined, diagramType: UMLDiagramType.ClassDiagram, studentParticipations: [{ id: 1 }] } as ModelingExercise; course.isAtLeastInstructor = true; - comp.exercise = new ModelingExercise(UMLDiagramType.ClassDiagram, course, undefined); - comp.submission = mockSubmission; + component.exercise = exercise; + component.submission = mockSubmission; + + fixture.detectChanges(); + + const modelingSubmissionComponent = fixture.debugElement.query(By.directive(ModelingSubmissionComponent))?.componentInstance; + expect(modelingSubmissionComponent).toBeTruthy(); + }); + + it('should not show modeling submission when there is no submission or exercise', () => { fixture.detectChanges(); - const modelingEditor = fixture.debugElement.query(By.directive(ModelingEditorComponent)); - expect(modelingEditor).not.toBeNull(); - const modelingEditorComponent = modelingEditor.componentInstance; - expect(modelingEditorComponent.diagramType).toEqual(UMLDiagramType.ClassDiagram); - expect(modelingEditorComponent.umlModel).toEqual({ model: true }); - expect(modelingEditorComponent.readOnly).toBeTrue(); - expect(modelingEditorComponent.withExplanation).toEqual(!!mockSubmission.explanationText); - expect(modelingEditorComponent.explanation).toEqual(mockSubmission.explanationText); + + const modelingSubmissionComponent = fixture.debugElement.query(By.directive(ModelingSubmissionComponent))?.componentInstance; + expect(modelingSubmissionComponent).not.toBeTruthy(); }); }); diff --git a/src/test/javascript/spec/component/exam/participate/summary/exercises/programming-exam-summary.component.spec.ts b/src/test/javascript/spec/component/exam/participate/summary/exercises/programming-exam-summary.component.spec.ts new file mode 100644 index 000000000000..22e88bb64873 --- /dev/null +++ b/src/test/javascript/spec/component/exam/participate/summary/exercises/programming-exam-summary.component.spec.ts @@ -0,0 +1,178 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ExerciseType } from 'app/entities/exercise.model'; +import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; +import { ProgrammingExamSummaryComponent } from 'app/exam/participate/summary/exercises/programming-exam-summary/programming-exam-summary.component'; +import { CloneRepoButtonComponent } from 'app/shared/components/clone-repo-button/clone-repo-button.component'; +import { FeedbackComponent } from 'app/exercises/shared/feedback/feedback.component'; +import { ProgrammingExerciseInstructionComponent } from 'app/exercises/programming/shared/instructions-render/programming-exercise-instruction.component'; +import { ComplaintsStudentViewComponent } from 'app/complaints/complaints-for-students/complaints-student-view.component'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { ExerciseCacheService } from 'app/exercises/shared/exercise/exercise-cache.service'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { ProgrammingExercise } from 'app/entities/programming-exercise.model'; +import { ProgrammingSubmission } from 'app/entities/programming-submission.model'; +import { User } from 'app/core/user/user.model'; +import { ProgrammingExerciseStudentParticipation } from 'app/entities/participation/programming-exercise-student-participation.model'; +import { Exam } from 'app/entities/exam.model'; +import { ExerciseGroup } from 'app/entities/exercise-group.model'; +import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; +import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; +import { BehaviorSubject } from 'rxjs'; +import { MockProfileService } from '../../../../../helpers/mocks/service/mock-profile.service'; +import { SubmissionType } from 'app/entities/submission.model'; +import { ParticipationType } from 'app/entities/participation/participation.model'; +import { By } from '@angular/platform-browser'; +import dayjs from 'dayjs/esm'; +import { Result } from 'app/entities/result.model'; +import { Feedback } from 'app/entities/feedback.model'; +import { AssessmentType } from 'app/entities/assessment-type.model'; + +const user = { id: 1, name: 'Test User' } as User; + +const exam = { + id: 1, + title: 'ExamForTesting', + latestIndividualEndDate: dayjs().subtract(10, 'minutes'), +} as Exam; + +const exerciseGroup = { + exam, + title: 'exercise group', +} as ExerciseGroup; + +const programmingSubmission = { + id: 1, + type: SubmissionType.MANUAL, + commitHash: '123456789ab', +} as ProgrammingSubmission; + +const programmingParticipation = { + id: 4, + student: user, + submissions: [programmingSubmission], + type: ParticipationType.PROGRAMMING, + participantIdentifier: 'student1', + repositoryUrl: 'https://bitbucket.ase.in.tum.de/projects/TEST/repos/test-exercise', +} as ProgrammingExerciseStudentParticipation; + +const programmingExercise = { + id: 4, + type: ExerciseType.PROGRAMMING, + studentParticipations: [programmingParticipation], + exerciseGroup, + projectKey: 'TEST', + dueDate: dayjs().subtract(5, 'minutes'), +} as ProgrammingExercise; + +const feedbackReference = { + id: 1, + result: { id: 2 } as Result, + hasLongFeedback: false, +} as Feedback; + +const feedback = { + type: 'Test', + name: 'artemisApp.result.detail.feedback', + title: 'artemisApp.result.detail.test.passedTest', + positive: true, + credits: 3, + feedbackReference, +}; + +const result = { + id: 89, + participation: { + id: 55, + type: ParticipationType.PROGRAMMING, + participantIdentifier: 'student42', + repositoryUrl: 'https://bitbucket.ase.in.tum.de/projects/somekey/repos/somekey-student42', + }, + feedbacks: [feedback], + assessmentType: AssessmentType.MANUAL, +} as Result; + +describe('ProgrammingExamSummaryComponent', () => { + let component: ProgrammingExamSummaryComponent; + let fixture: ComponentFixture; + + let profileService: ProfileService; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + ProgrammingExamSummaryComponent, + MockComponent(CloneRepoButtonComponent), + MockComponent(FeedbackComponent), + MockComponent(ProgrammingExerciseInstructionComponent), + MockComponent(ComplaintsStudentViewComponent), + MockPipe(ArtemisTranslatePipe), + ], + providers: [MockProvider(ExerciseService), MockProvider(ExerciseCacheService), { provide: ProfileService, useValue: new MockProfileService() }], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(ProgrammingExamSummaryComponent); + component = fixture.componentInstance; + + component.exercise = programmingExercise; + programmingParticipation.results = [result]; + component.participation = programmingParticipation; + component.submission = programmingSubmission; + component.exam = exam; + + profileService = TestBed.inject(ProfileService); + + const commitHashURLTemplate = 'https://bitbucket.ase.in.tum.de/projects/{projectKey}/repos/{repoSlug}/commits/{commitHash}'; + const profileInfo = new ProfileInfo(); + profileInfo.commitHashURLTemplate = commitHashURLTemplate; + jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(new BehaviorSubject(profileInfo)); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should initialize', () => { + fixture.detectChanges(); + + expect(component).toBeTruthy(); + }); + + it('should set commitUrl', () => { + const spyOnGetProfileInfo = jest.spyOn(profileService, 'getProfileInfo'); + + fixture.detectChanges(); + + expect(spyOnGetProfileInfo).toHaveBeenCalledOnce(); + expect(component.commitHash).toBe('123456789ab'); + expect(component.commitUrl).toBe('https://bitbucket.ase.in.tum.de/projects/test/repos/test-student1/commits/123456789ab'); + }); + + it('should show result if present and results are published', () => { + component.isAfterResultsArePublished = true; + + fixture.detectChanges(); + + expect(component.feedbackComponentParameters.exercise).toEqual(programmingExercise); + expect(component.feedbackComponentParameters.result).toEqual(result); + expect(component.feedbackComponentParameters.exerciseType).toEqual(programmingExercise.type); + + const modelingSubmissionComponent = fixture.debugElement.query(By.directive(FeedbackComponent))?.componentInstance; + expect(modelingSubmissionComponent).toBeTruthy(); + }); + + it('should not show results if not yet published', () => { + component.isAfterResultsArePublished = false; + + fixture.detectChanges(); + + const modelingSubmissionComponent = fixture.debugElement.query(By.directive(FeedbackComponent))?.componentInstance; + expect(modelingSubmissionComponent).not.toBeTruthy(); + }); + + it('should display clone button', () => { + const modelingSubmissionComponent = fixture.debugElement.query(By.directive(CloneRepoButtonComponent))?.componentInstance; + expect(modelingSubmissionComponent).toBeTruthy(); + }); +}); diff --git a/src/test/javascript/spec/component/exam/participate/summary/exercises/quiz-exam-summary.component.spec.ts b/src/test/javascript/spec/component/exam/participate/summary/exercises/quiz-exam-summary.component.spec.ts index 1806bf75df06..a6a54f521bfb 100644 --- a/src/test/javascript/spec/component/exam/participate/summary/exercises/quiz-exam-summary.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/summary/exercises/quiz-exam-summary.component.spec.ts @@ -26,8 +26,7 @@ import { ArtemisQuizQuestionTypesModule } from 'app/exercises/quiz/shared/questi import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { ArtemisServerDateService } from 'app/shared/server-date.service'; import dayjs from 'dayjs/esm'; -import { MockModule, MockProvider } from 'ng-mocks'; -import { MockPipe } from 'ng-mocks'; +import { MockModule, MockPipe, MockProvider } from 'ng-mocks'; const multipleChoiceQuestion = { id: 1, type: QuizQuestionType.MULTIPLE_CHOICE } as MultipleChoiceQuestion; const wrongAnswerOption = { id: 1, isCorrect: false, question: multipleChoiceQuestion } as AnswerOption; diff --git a/src/test/javascript/spec/component/exam/participate/summary/exercises/text-exam-summary.component.spec.ts b/src/test/javascript/spec/component/exam/participate/summary/exercises/text-exam-summary.component.spec.ts index dfb41fbc5034..7606478c4e1c 100644 --- a/src/test/javascript/spec/component/exam/participate/summary/exercises/text-exam-summary.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/summary/exercises/text-exam-summary.component.spec.ts @@ -1,13 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { TextExamSummaryComponent } from 'app/exam/participate/summary/exercises/text-exam-summary/text-exam-summary.component'; import { TextSubmission } from 'app/entities/text-submission.model'; +import { Exercise } from 'app/entities/exercise.model'; +import { TextEditorComponent } from 'app/exercises/text/participate/text-editor.component'; +import { MockComponent } from 'ng-mocks'; +import { By } from '@angular/platform-browser'; // Import By describe('TextExamSummaryComponent', () => { let fixture: ComponentFixture; let component: TextExamSummaryComponent; beforeEach(() => { - TestBed.configureTestingModule({ declarations: [TextExamSummaryComponent] }) + TestBed.configureTestingModule({ declarations: [TextExamSummaryComponent, MockComponent(TextEditorComponent)] }) .compileComponents() .then(() => { fixture = TestBed.createComponent(TextExamSummaryComponent); @@ -18,14 +22,18 @@ describe('TextExamSummaryComponent', () => { it('should initialize', () => { fixture.detectChanges(); expect(component).not.toBeNull(); - expect(fixture.debugElement.nativeElement.querySelector('div').innerHTML).toBe('No submission'); + expect(fixture.debugElement.nativeElement.querySelector('div').innerHTML).toContain('No submission'); }); it('should display the submission text', () => { const submissionText = 'A test submission text'; component.submission = { text: submissionText } as TextSubmission; + component.exercise = { studentParticipations: [{ id: 1 }] } as Exercise; fixture.detectChanges(); - expect(component).not.toBeNull(); - expect(fixture.debugElement.nativeElement.querySelector('div').innerHTML).toEqual(submissionText); + + const textEditorComponent = fixture.debugElement.query(By.directive(TextEditorComponent)).componentInstance; + expect(textEditorComponent).not.toBeNull(); + expect(textEditorComponent.participationId).toBe(1); + expect(textEditorComponent.inputSubmission.text).toBe(submissionText); }); }); diff --git a/src/test/javascript/spec/component/exam/participate/summary/result-overview/exam-result-overview.component.spec.ts b/src/test/javascript/spec/component/exam/participate/summary/result-overview/exam-result-overview.component.spec.ts index 8ed3b7b1de23..d61a00d3b545 100644 --- a/src/test/javascript/spec/component/exam/participate/summary/result-overview/exam-result-overview.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/summary/result-overview/exam-result-overview.component.spec.ts @@ -271,7 +271,7 @@ describe('ExamResultOverviewComponent', () => { component.scrollToExercise(INVALID_EXERCISE_ID); - expect(consoleErrorMock).toHaveBeenCalledWith(expect.stringContaining('Could not find corresponding exercise with id')); + expect(consoleErrorMock).toHaveBeenCalledWith(expect.stringContaining('Cannot scroll to exercise, could not find exercise with corresponding id')); }); it('should return immediately when exerciseId is undefined', () => { @@ -282,41 +282,4 @@ describe('ExamResultOverviewComponent', () => { expect(consoleErrorSpy).not.toHaveBeenCalled(); }); }); - - describe('getAchievedPercentageByExerciseId', () => { - it('should return undefined if exercise result is undefined', () => { - component.studentExamWithGrade.studentResult.exerciseGroupIdToExerciseResult = {}; - const scoreAsPercentage = component.getAchievedPercentageByExerciseId(textExercise.id); - - expect(scoreAsPercentage).toBeUndefined(); - }); - - it('should calculate percentage based on achievedScore considering course settings', () => { - textExerciseResult.achievedScore = 60.6666; - - const scoreAsPercentage = component.getAchievedPercentageByExerciseId(textExercise.id); - - expect(scoreAsPercentage).toBe(60.67); - }); - - it('should calculate percentage based on maxScore and achievedPoints', () => { - textExerciseResult.achievedScore = undefined; - textExerciseResult.maxScore = 10; - textExerciseResult.achievedPoints = 6.066666; - component.studentExamWithGrade.studentExam!.exam!.course!.accuracyOfScores = 3; - - const scoreAsPercentage = component.getAchievedPercentageByExerciseId(textExercise.id); - - expect(scoreAsPercentage).toBe(60.667); - }); - - it('should return undefined if not set and not calculable', () => { - textExerciseResult.achievedScore = undefined; - textExerciseResult.achievedPoints = undefined; - - const scoreAsPercentage = component.getAchievedPercentageByExerciseId(textExercise.id); - - expect(scoreAsPercentage).toBeUndefined(); - }); - }); }); diff --git a/src/test/javascript/spec/component/file-upload-submission/file-upload-submission.component.spec.ts b/src/test/javascript/spec/component/file-upload-submission/file-upload-submission.component.spec.ts index 7d4db3d8ac14..1e52b847a7fb 100644 --- a/src/test/javascript/spec/component/file-upload-submission/file-upload-submission.component.spec.ts +++ b/src/test/javascript/spec/component/file-upload-submission/file-upload-submission.component.spec.ts @@ -16,7 +16,7 @@ import { MockComplaintService } from '../../helpers/mocks/service/mock-complaint import { NgxDatatableModule } from '@flaviosantoro92/ngx-datatable'; import { routes } from 'app/exercises/file-upload/participate/file-upload-participation.route'; import { FileUploadSubmissionComponent } from 'app/exercises/file-upload/participate/file-upload-submission.component'; -import { MockFileUploadSubmissionService, createFileUploadSubmission } from '../../helpers/mocks/service/mock-file-upload-submission.service'; +import { MockFileUploadSubmissionService, createFileUploadSubmission, fileUploadParticipation } from '../../helpers/mocks/service/mock-file-upload-submission.service'; import { ParticipationWebsocketService } from 'app/overview/participation-websocket.service'; import { fileUploadExercise } from '../../helpers/mocks/service/mock-file-upload-exercise.service'; import { MAX_SUBMISSION_FILE_SIZE } from 'app/shared/constants/input.constants'; @@ -396,4 +396,25 @@ describe('FileUploadSubmissionComponent', () => { expect(comp.submittedFileName).toBe(fileName + '.pdf'); expect(comp.submittedFileExtension).toBe('pdf'); }); + + it('should be set up with input values if present instead of loading new values from server', () => { + // @ts-ignore method is private + const setUpComponentWithInputValuesSpy = jest.spyOn(comp, 'setupComponentWithInputValues'); + const getDataForFileUploadEditorSpy = jest.spyOn(fileUploadSubmissionService, 'getDataForFileUploadEditor'); + const fileUploadSubmission = createFileUploadSubmission(); + fileUploadSubmission.submitted = true; + comp.inputExercise = fileUploadExercise; + comp.inputSubmission = fileUploadSubmission; + comp.inputParticipation = fileUploadParticipation; + + fixture.detectChanges(); + + expect(setUpComponentWithInputValuesSpy).toHaveBeenCalledOnce(); + expect(comp.fileUploadExercise).toEqual(fileUploadExercise); + expect(comp.submission).toEqual(fileUploadSubmission); + expect(comp.participation).toEqual(fileUploadParticipation); + + // should not fetch additional information from server, reason for input values! + expect(getDataForFileUploadEditorSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts b/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts index ac0d270ae7ea..e152d95a6df4 100644 --- a/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts +++ b/src/test/javascript/spec/component/modeling-submission/modeling-submission.component.spec.ts @@ -10,7 +10,7 @@ import { MockParticipationWebsocketService } from '../../helpers/mocks/service/m import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; import { TranslateService } from '@ngx-translate/core'; import { RouterTestingModule } from '@angular/router/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { ParticipationWebsocketService } from 'app/overview/participation-websocket.service'; import { ChangeDetectorRef, DebugElement } from '@angular/core'; import { By } from '@angular/platform-browser'; @@ -44,7 +44,7 @@ import { HttpResponse } from '@angular/common/http'; import { GradingInstruction } from 'app/exercises/shared/structured-grading-criterion/grading-instruction.model'; import { AlertService } from 'app/core/util/alert.service'; -describe('ModelingSubmission Management Component', () => { +describe('ModelingSubmissionComponent', () => { // needed to make sure ace is defined ace.acequire('ace/ext/modelist.js'); let comp: ModelingSubmissionComponent; @@ -52,7 +52,6 @@ describe('ModelingSubmission Management Component', () => { let debugElement: DebugElement; let service: ModelingSubmissionService; let alertService: AlertService; - let router: Router; const route = { params: of({ courseId: 5, exerciseId: 22, participationId: 123 }) } as any as ActivatedRoute; const participation = new StudentParticipation(); @@ -99,7 +98,6 @@ describe('ModelingSubmission Management Component', () => { debugElement = fixture.debugElement; service = debugElement.injector.get(ModelingSubmissionService); alertService = debugElement.injector.get(AlertService); - router = debugElement.injector.get(Router); comp.modelingEditor = TestBed.createComponent(MockComponent(ModelingEditorComponent)).componentInstance; }); console.error = jest.fn(); @@ -191,11 +189,12 @@ describe('ModelingSubmission Management Component', () => { expect(comp.isActive).toBeFalse(); }); - it('should navigate to access denied page on 403 error status', () => { + it('should catch error on 403 error status', () => { jest.spyOn(service, 'getLatestSubmissionForModelingEditor').mockReturnValue(throwError(() => ({ status: 403 }))); - const routerStub = jest.spyOn(router, 'navigate').mockReturnValue(new Promise(() => true)); + const alertServiceSpy = jest.spyOn(alertService, 'error'); fixture.detectChanges(); - expect(routerStub).toHaveBeenCalledOnce(); + + expect(alertServiceSpy).toHaveBeenCalledOnce(); }); it('should set correct properties on modeling exercise update when saving', () => { @@ -464,4 +463,33 @@ describe('ModelingSubmission Management Component', () => { expect(referencedFeedback).toHaveLength(1); expect(referencedFeedback![0].isSubsequent).toBeTrue(); }); + + it('should be set up with input values if present instead of loading new values from server', () => { + // @ts-ignore method is private + const setUpComponentWithInputValuesSpy = jest.spyOn(comp, 'setupComponentWithInputValues'); + const getDataForFileUploadEditorSpy = jest.spyOn(service, 'getLatestSubmissionForModelingEditor'); + const modelingSubmission = submission; + modelingSubmission.model = JSON.stringify({ + elements: [ + { + content: 'some element', + }, + ], + }); + comp.inputExercise = participation.exercise; + comp.inputSubmission = modelingSubmission; + comp.inputParticipation = participation; + + fixture.detectChanges(); + + expect(setUpComponentWithInputValuesSpy).toHaveBeenCalledOnce(); + expect(comp.modelingExercise).toEqual(participation.exercise); + expect(comp.submission).toEqual(modelingSubmission); + expect(comp.participation).toEqual(participation); + expect(comp.umlModel).toBeTruthy(); + expect(comp.hasElements).toBeTrue(); + + // should not fetch additional information from server, reason for input values! + expect(getDataForFileUploadEditorSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/javascript/spec/component/shared/feedback/feedback.component.spec.ts b/src/test/javascript/spec/component/shared/feedback/feedback-modal.component.spec.ts similarity index 90% rename from src/test/javascript/spec/component/shared/feedback/feedback.component.spec.ts rename to src/test/javascript/spec/component/shared/feedback/feedback-modal.component.spec.ts index 1f76e23f389e..658995d817d7 100644 --- a/src/test/javascript/spec/component/shared/feedback/feedback.component.spec.ts +++ b/src/test/javascript/spec/component/shared/feedback/feedback-modal.component.spec.ts @@ -26,6 +26,8 @@ import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks'; import { BehaviorSubject, of, throwError } from 'rxjs'; import { TranslatePipeMock } from '../../../helpers/mocks/service/mock-translate.service'; import { ArtemisTestModule } from '../../../test.module'; +import { SimpleChange } from '@angular/core'; +import { FeedbackGroup } from 'app/exercises/shared/feedback/group/feedback-group'; describe('FeedbackComponent', () => { let comp: FeedbackComponent; @@ -273,7 +275,7 @@ describe('FeedbackComponent', () => { comp.ngOnInit(); expect(comp.getCommitHash()).toBe('123456789ab'); - expect(comp.getCommitUrl()).toBe('https://bitbucket.ase.in.tum.de/projects/somekey/repos/somekey-student42/commits/123456789ab'); + expect(comp.commitUrl).toBe('https://bitbucket.ase.in.tum.de/projects/somekey/repos/somekey-student42/commits/123456789ab'); }); it('should not try to retrieve the feedbacks from the server if provided result has feedbacks', () => { @@ -408,4 +410,36 @@ describe('FeedbackComponent', () => { expect(createSpy).toHaveBeenCalledWith(feedbacks, true); }); + + it('should expand feedback when being printed', () => { + // @ts-ignore method is private + const expandFeedbackItemGroupsSpy = jest.spyOn(comp, 'expandFeedbackItemGroups'); + + const feedbackItem = generateManualFeedbackPair(true, 'Positive', 'This is good', 4).item; + const feedbackItem1 = generateManualFeedbackPair(true, 'Positive', 'This is good', 4).item; + + const feedbackGroup: FeedbackGroup = { ...feedbackItem, members: [feedbackItem1], open: false } as unknown as FeedbackGroup; + comp.feedbackItemNodes = [feedbackGroup]; + + // start printing => expand feedback + const previousValue = undefined; + const currentValue = true; + const firstChange = false; + const startPrinting = new SimpleChange(previousValue, currentValue, firstChange); + comp.ngOnChanges({ isPrinting: startPrinting }); + + expect(expandFeedbackItemGroupsSpy).toHaveBeenCalledOnce(); + expect(feedbackGroup.open).toBeTrue(); + + // stop printing => collapse feedback (as it was collapsed before) + const stopPrinting = new SimpleChange(true, false, false); + comp.ngOnChanges({ isPrinting: stopPrinting }); + + expect(expandFeedbackItemGroupsSpy).toHaveBeenCalledOnce(); // should not have been called again + + /** + * references were removed during saving old state => cannot use {@link feedbackGroup} for comparison anymore + */ + expect((comp.feedbackItemNodes[0] as unknown as FeedbackGroup).open).toBeFalse(); + }); }); diff --git a/src/test/javascript/spec/component/shared/notification/notification-sidebar.component.spec.ts b/src/test/javascript/spec/component/shared/notification/notification-sidebar.component.spec.ts index 1f0595cb0105..8cf036109027 100644 --- a/src/test/javascript/spec/component/shared/notification/notification-sidebar.component.spec.ts +++ b/src/test/javascript/spec/component/shared/notification/notification-sidebar.component.spec.ts @@ -128,6 +128,18 @@ describe('Notification Sidebar Component', () => { expect(notificationService.subscribeToLoadingStateUpdates).toHaveBeenCalledOnce(); expect(notificationSidebarComponent.sortedNotifications).toHaveLength(notifications.length); }); + + /** + * The notification-sidebar overlaps the exam summary when being printed, + * so it needs to be hidden for printing for which the display property of + * the notification sidebar is used + * + * If the id is changed it needs to be changed in {@link ThemeService#print} as well + */ + test('should exist with the id "notification-sidebar"', () => { + const notificationSidebar = document.getElementById('notification-sidebar'); + expect(notificationSidebar).toBeTruthy(); + }); }); describe('Notification Translations', () => { diff --git a/src/test/javascript/spec/component/shared/result.component.spec.ts b/src/test/javascript/spec/component/shared/result.component.spec.ts index 51065b477f6d..642a522d5328 100644 --- a/src/test/javascript/spec/component/shared/result.component.spec.ts +++ b/src/test/javascript/spec/component/shared/result.component.spec.ts @@ -5,21 +5,77 @@ import { ResultTemplateStatus } from 'app/exercises/shared/result/result.utils'; import { SimpleChange } from '@angular/core'; import { TranslatePipeMock } from '../../helpers/mocks/service/mock-translate.service'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; +import { Result } from 'app/entities/result.model'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { MockPipe } from 'ng-mocks'; +import { ArtemisTimeAgoPipe } from 'app/shared/pipes/artemis-time-ago.pipe'; +import { Exercise, ExerciseType } from 'app/entities/exercise.model'; +import { AssessmentType } from 'app/entities/assessment-type.model'; +import { Participation, ParticipationType } from 'app/entities/participation/participation.model'; +import dayjs from 'dayjs/esm'; +import { NgbTooltipMocksModule } from '../../helpers/mocks/directive/ngbTooltipMocks.module'; +import { MockNgbModalService } from '../../helpers/mocks/service/mock-ngb-modal.service'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import * as utils from 'app/exercises/shared/feedback/feedback.utils'; +import { FeedbackComponentPreparedParams } from 'app/exercises/shared/feedback/feedback.utils'; +import { FeedbackComponent } from 'app/exercises/shared/feedback/feedback.component'; + +const mockExercise: Exercise = { + id: 1, + title: 'Sample Exercise', + maxPoints: 100, + dueDate: dayjs().subtract(3, 'hours'), + assessmentType: AssessmentType.AUTOMATIC, +} as Exercise; + +const mockParticipation: Participation = { + id: 1, + type: ParticipationType.STUDENT, + exercise: mockExercise, +}; + +const mockResult: Result = { + id: 1, + completionDate: dayjs().subtract(2, 'hours'), + score: 85, + rated: true, + feedbacks: [ + { + id: 1, + text: 'Well done!', + }, + ], + participation: mockParticipation, +}; + +const preparedFeedback: FeedbackComponentPreparedParams = { + exercise: mockExercise, + result: mockResult, + exerciseType: ExerciseType.PROGRAMMING, + showScoreChart: true, + messageKey: 'artemisApp.result.notLatestSubmission', + latestDueDate: dayjs().subtract(1, 'hours'), + showMissingAutomaticFeedbackInformation: true, +}; describe('ResultComponent', () => { let comp: ResultComponent; let fixture: ComponentFixture; + let modalService: NgbModal; beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule], - declarations: [ResultComponent, TranslatePipeMock], - providers: [], + imports: [ArtemisTestModule, NgbTooltipMocksModule], + declarations: [ResultComponent, TranslatePipeMock, MockPipe(ArtemisDatePipe), MockPipe(ArtemisTimeAgoPipe)], + providers: [{ provide: NgbModal, useClass: MockNgbModalService }], }) .compileComponents() .then(() => { fixture = TestBed.createComponent(ResultComponent); comp = fixture.componentInstance; + modalService = TestBed.inject(NgbModal); + + fixture.detectChanges(); }); }); @@ -49,4 +105,54 @@ describe('ResultComponent', () => { expect(comp.templateStatus).toEqual(ResultTemplateStatus.IS_BUILDING); }); + + describe('should display HAS_RESULT status properly', () => { + const RESULT_SCORE_SELECTOR = '#result-score'; + + it('should not display if result is not present', () => { + const button = fixture.debugElement.nativeElement.querySelector(RESULT_SCORE_SELECTOR); + expect(button).not.toBeTruthy(); + }); + + it('should display result if present', () => { + comp.result = mockResult; + comp.templateStatus = ResultTemplateStatus.HAS_RESULT; + + fixture.detectChanges(); + + const button = fixture.debugElement.nativeElement.querySelector(RESULT_SCORE_SELECTOR); + expect(button).toBeTruthy(); + }); + + it('should display modal onClick and initialize results modal', () => { + const mockModalRef: NgbModalRef = { componentInstance: {} } as NgbModalRef; + const modalComponentInstance: FeedbackComponent = mockModalRef.componentInstance; + + const showDetailsSpy = jest.spyOn(comp, 'showDetails'); + const openModalSpy = jest.spyOn(modalService, 'open').mockReturnValue(mockModalRef); + const prepareFeedbackSpy = jest.spyOn(utils, 'prepareFeedbackComponentParameters').mockReturnValue(preparedFeedback); + + comp.exercise = preparedFeedback.exercise; + comp.result = mockResult; + comp.templateStatus = ResultTemplateStatus.HAS_RESULT; + + fixture.detectChanges(); + + const button = fixture.debugElement.nativeElement.querySelector(RESULT_SCORE_SELECTOR); + expect(button).toBeTruthy(); + + button.dispatchEvent(new Event('click')); + + expect(showDetailsSpy).toHaveBeenCalled(); + expect(openModalSpy).toHaveBeenCalled(); + expect(prepareFeedbackSpy).toHaveBeenCalledOnce(); + expect(modalComponentInstance.exercise).toEqual(preparedFeedback.exercise); + expect(modalComponentInstance.result).toEqual(preparedFeedback.result); + expect(modalComponentInstance.exerciseType).toEqual(preparedFeedback.exerciseType); + expect(modalComponentInstance.showScoreChart).toEqual(preparedFeedback.showScoreChart); + expect(modalComponentInstance.messageKey).toEqual(preparedFeedback.messageKey); + expect(modalComponentInstance.latestDueDate).toEqual(preparedFeedback.latestDueDate); + expect(modalComponentInstance.showMissingAutomaticFeedbackInformation).toEqual(preparedFeedback.showMissingAutomaticFeedbackInformation); + }); + }); }); diff --git a/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts b/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts index d08cc2d5d920..653989626b68 100644 --- a/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts +++ b/src/test/javascript/spec/component/text-editor/text-editor.component.spec.ts @@ -113,6 +113,22 @@ describe('TextEditorComponent', () => { jest.restoreAllMocks(); }); + it('should use inputValues if present instead of loading new details', fakeAsync(() => { + comp.inputExercise = textExercise; + comp.inputParticipation = participation; + + // @ts-ignore updateParticipation is private + const updateParticipationSpy = jest.spyOn(comp, 'updateParticipation'); + // @ts-ignore setupComponentWithInputValuesSpy is private + const setupComponentWithInputValuesSpy = jest.spyOn(comp, 'setupComponentWithInputValues'); + + fixture.detectChanges(); + + expect(getTextForParticipationStub).not.toHaveBeenCalled(); + expect(updateParticipationSpy).not.toHaveBeenCalled(); + expect(setupComponentWithInputValuesSpy).toHaveBeenCalled(); + })); + it('should not allow to submit after the due date if there is no due date', fakeAsync(() => { const participationSubject = new BehaviorSubject(participation); getTextForParticipationStub.mockReturnValue(participationSubject); diff --git a/src/test/javascript/spec/integration/course/course-overview-exercise-list-controls.integration.spec.ts b/src/test/javascript/spec/integration/course/course-overview-exercise-list-controls.integration.spec.ts index 16c4860fc8f2..1a6c70291a98 100644 --- a/src/test/javascript/spec/integration/course/course-overview-exercise-list-controls.integration.spec.ts +++ b/src/test/javascript/spec/integration/course/course-overview-exercise-list-controls.integration.spec.ts @@ -23,6 +23,8 @@ import { ActivatedRoute, Router } from '@angular/router'; import { MockRouter } from '../../helpers/mocks/mock-router'; import { NgModel } from '@angular/forms'; import { CourseStorageService } from 'app/course/manage/course-storage.service'; +import { CourseExercisesGroupedByCategoryComponent } from 'app/overview/course-exercises/course-exercises-grouped-by-category.component'; +import { CourseExercisesGroupedByWeekComponent } from 'app/overview/course-exercises/course-exercises-grouped-by-week.component'; describe('CourseOverviewExerciseListControls', () => { let parentFixture: ComponentFixture; @@ -47,6 +49,8 @@ describe('CourseOverviewExerciseListControls', () => { MockDirective(NgModel), MockComponent(SidePanelComponent), MockComponent(HeaderCourseComponent), + MockComponent(CourseExercisesGroupedByCategoryComponent), + MockComponent(CourseExercisesGroupedByWeekComponent), TranslatePipeMock, MockPipe(ArtemisDatePipe), MockTranslateValuesDirective, diff --git a/src/test/javascript/spec/service/theme.service.spec.ts b/src/test/javascript/spec/service/theme.service.spec.ts index 7a0f6bcc7c23..16441b4c4bd8 100644 --- a/src/test/javascript/spec/service/theme.service.spec.ts +++ b/src/test/javascript/spec/service/theme.service.spec.ts @@ -141,8 +141,10 @@ describe('ThemeService', () => { }); it('does print correctly', fakeAsync(() => { + const initialDisplayClass = 'someDisplayClass'; + const winSpy = jest.spyOn(window, 'print').mockImplementation(); - const returnedElement = { rel: 'stylesheet' }; + const returnedElement = { rel: 'stylesheet', style: { display: initialDisplayClass } }; const docSpy = jest.spyOn(document, 'getElementById').mockReturnValue(returnedElement as any as HTMLElement); service.print(); @@ -151,8 +153,11 @@ describe('ThemeService', () => { expect(docSpy).toHaveBeenCalledWith(THEME_OVERRIDE_ID); expect(returnedElement.rel).toBe('none-tmp'); tick(250); + expect(docSpy).toHaveBeenCalledWith('notification-sidebar'); + expect(docSpy).toHaveBeenCalledTimes(3); // 1x for theme override, 2x for notification sidebar (changing style to display: none and back to initial value) expect(winSpy).toHaveBeenCalledOnce(); tick(250); expect(returnedElement.rel).toBe('stylesheet'); + expect(returnedElement.style.display).toBe(initialDisplayClass); })); });