From 6e5e558ce34528a14ff79a5e0be2dc29d1eba914 Mon Sep 17 00:00:00 2001 From: Patrik Zander <38403547+pzdr7@users.noreply.github.com> Date: Fri, 11 Oct 2024 23:39:43 +0200 Subject: [PATCH 1/6] Development: Update monaco-editor to 0.52.0 (#9431) --- angular.json | 2 +- package-lock.json | 9 +++--- package.json | 2 +- prebuild.mjs | 30 ++++++++++++++++--- .../webapp/app/core/config/monaco.config.ts | 26 +++++++++------- .../adapter/monaco-text-editor.adapter.ts | 4 +-- .../actions/adapter/text-editor.interface.ts | 5 ++-- .../communication/channel-reference.action.ts | 5 ++-- .../exercise-reference.action.ts | 7 +++-- .../communication/user-mention.action.ts | 7 +++-- .../model/actions/text-editor-action.model.ts | 9 ------ 11 files changed, 62 insertions(+), 44 deletions(-) diff --git a/angular.json b/angular.json index e5543ff2ce60..008ac75d13bf 100644 --- a/angular.json +++ b/angular.json @@ -113,7 +113,7 @@ }, { "glob": "**/*", - "input": "./node_modules/monaco-editor/min/vs", + "input": "./node_modules/monaco-editor/bundles/vs", "output": "vs" } ], diff --git a/package-lock.json b/package-lock.json index c72840ee901d..4b7bc1437ba6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "jszip": "3.10.1", "lodash-es": "4.17.21", "mobile-drag-drop": "3.0.0-rc.0", - "monaco-editor": "0.51.0", + "monaco-editor": "0.52.0", "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", @@ -16958,10 +16958,9 @@ "license": "MIT" }, "node_modules/monaco-editor": { - "version": "0.51.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.51.0.tgz", - "integrity": "sha512-xaGwVV1fq343cM7aOYB6lVE4Ugf0UyimdD/x5PWcWBMKENwectaEu77FAN7c5sFiyumqeJdX1RPTh1ocioyDjw==", - "license": "MIT" + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.0.tgz", + "integrity": "sha512-OeWhNpABLCeTqubfqLMXGsqf6OmPU6pHM85kF3dhy6kq5hnhuVS1p3VrEW/XhWHc71P2tHyS5JFySD8mgs1crw==" }, "node_modules/moo-color": { "version": "1.0.3", diff --git a/package.json b/package.json index dfe192b8d21b..b1063f9a90e0 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "jszip": "3.10.1", "lodash-es": "4.17.21", "mobile-drag-drop": "3.0.0-rc.0", - "monaco-editor": "0.51.0", + "monaco-editor": "0.52.0", "ngx-infinite-scroll": "18.0.0", "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", diff --git a/prebuild.mjs b/prebuild.mjs index 7ef783be432c..5f362babe3fc 100644 --- a/prebuild.mjs +++ b/prebuild.mjs @@ -5,10 +5,11 @@ * - webpack.DefinePlugin and * - MergeJsonWebpackPlugin */ -import fs from "fs"; -import path from "path"; -import { hashElement } from "folder-hash"; -import { fileURLToPath } from "url"; +import fs from 'fs'; +import path from 'path'; +import { hashElement } from 'folder-hash'; +import { fileURLToPath } from 'url'; +import * as esbuild from 'esbuild'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -111,4 +112,25 @@ for (const group of groups) { } } +/* + * The workers of the monaco editor must be bundled separately. + * Specialized workers are available in the vs/esm/language/ directory. + * Be sure to modify the MonacoConfig if you choose to add a worker here. + * For more details, refer to https://github.com/microsoft/monaco-editor/blob/main/samples/browser-esm-esbuild/build.js + */ +const workerEntryPoints = [ + 'vs/language/json/json.worker.js', + 'vs/language/css/css.worker.js', + 'vs/language/html/html.worker.js', + 'vs/language/typescript/ts.worker.js', + 'vs/editor/editor.worker.js' +]; +await esbuild.build({ + entryPoints: workerEntryPoints.map((entry) => `node_modules/monaco-editor/esm/${entry}`), + bundle: true, + format: 'esm', + outbase: 'node_modules/monaco-editor/esm', + outdir: 'node_modules/monaco-editor/bundles' +}); + console.log("Pre-Build complete!"); diff --git a/src/main/webapp/app/core/config/monaco.config.ts b/src/main/webapp/app/core/config/monaco.config.ts index aa40e47c177c..f37dfe5a4069 100644 --- a/src/main/webapp/app/core/config/monaco.config.ts +++ b/src/main/webapp/app/core/config/monaco.config.ts @@ -1,19 +1,23 @@ /** * Sets up the MonacoEnvironment for the monaco editor's service worker. + * See https://github.com/microsoft/monaco-editor/blob/main/samples/browser-esm-esbuild/index.js */ export function MonacoConfig() { self.MonacoEnvironment = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getWorkerUrl: function (workerId: string, label: string) { - /* - * This is the AMD-based service worker, which comes bundled with a few special workers for selected languages. - * (e.g.: javascript, typescript, html, css) - * - * It is also possible to use an ESM-based approach, which requires a little more setup and case distinctions in this method. - * At the moment, it seems that the ESM-based approaches are incompatible with the Artemis client, as they would require custom builders. - * Support for custom builders was removed in #6546. - */ - return 'vs/base/worker/workerMain.js'; + getWorkerUrl: (_moduleId: string, label: string): string => { + if (label === 'json') { + return './vs/language/json/json.worker.js'; + } + if (label === 'css' || label === 'scss' || label === 'less') { + return './vs/language/css/css.worker.js'; + } + if (label === 'html' || label === 'handlebars' || label === 'razor') { + return './vs/language/html/html.worker.js'; + } + if (label === 'typescript' || label === 'javascript') { + return './vs/language/typescript/ts.worker.js'; + } + return './vs/editor/editor.worker.js'; }, }; } diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts index 7bd630b7d479..f55bbfe3e3f1 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter.ts @@ -152,8 +152,8 @@ export class MonacoTextEditorAdapter implements TextEditor { return this.editor.getDomNode() ?? undefined; } - typeText(text: string) { - this.editor.trigger('MonacoTextEditorAdapter::typeText', 'type', { text }); + triggerCompletion(): void { + this.editor.trigger('MonacoTextEditorAdapter::triggerCompletion', 'editor.action.triggerSuggest', {}); } getTextAtRange(range: TextEditorRange): string { diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts index b3403c274b64..b847645a2efd 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/adapter/text-editor.interface.ts @@ -42,10 +42,9 @@ export interface TextEditor { getDomNode(): HTMLElement | undefined; /** - * Types the given text into the editor as if the user had typed it, e.g. to trigger a completer registered in the editor. - * @param text The text to type into the editor. + * Triggers the completion in the editor, e.g. by showing a widget. */ - typeText(text: string): void; + triggerCompletion(): void; /** * Retrieves the text at the given range in the editor. diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/channel-reference.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/channel-reference.action.ts index f3bc053b43da..5afefaf5275d 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/channel-reference.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/channel-reference.action.ts @@ -44,11 +44,12 @@ export class ChannelReferenceAction extends TextEditorAction { } /** - * Types the text '#' into the editor and focuses it. This will trigger the completion provider to show the available channels. + * Inserts the text '#' into the editor and focuses it. This method will trigger the completion provider to show the available channels. * @param editor The editor to type the text into. */ run(editor: TextEditor) { - this.typeText(editor, ChannelReferenceAction.DEFAULT_INSERT_TEXT); + this.replaceTextAtCurrentSelection(editor, ChannelReferenceAction.DEFAULT_INSERT_TEXT); + editor.triggerCompletion(); editor.focus(); } diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/exercise-reference.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/exercise-reference.action.ts index 84fe668e955e..44b72e0f8724 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/exercise-reference.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/exercise-reference.action.ts @@ -52,11 +52,12 @@ export class ExerciseReferenceAction extends TextEditorDomainActionWithOptions { } /** - * Types the text '/exercise' into the editor and focuses it. This will trigger the completion provider to show the available exercises. - * @param editor The editor to type the text into. + * Inserts the text '/exercise' into the editor and focuses it. This method will trigger the completion provider to show the available exercises. + * @param editor The editor to insert the text into. */ run(editor: TextEditor): void { - this.typeText(editor, ExerciseReferenceAction.DEFAULT_INSERT_TEXT); + this.replaceTextAtCurrentSelection(editor, ExerciseReferenceAction.DEFAULT_INSERT_TEXT); + editor.triggerCompletion(); editor.focus(); } diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/user-mention.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/user-mention.action.ts index e6fa9b208397..240805b4adda 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/communication/user-mention.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/communication/user-mention.action.ts @@ -45,11 +45,12 @@ export class UserMentionAction extends TextEditorAction { } /** - * Types the text '@' into the editor and focuses it. This will trigger the completion provider to show the available users. - * @param editor The editor to type the text into. + * Inserts the text '@' into the editor and focuses it. This method will trigger the completion provider to show the available users. + * @param editor The editor to insert the text into. */ run(editor: TextEditor) { - this.typeText(editor, UserMentionAction.DEFAULT_INSERT_TEXT); + this.replaceTextAtCurrentSelection(editor, UserMentionAction.DEFAULT_INSERT_TEXT); + editor.triggerCompletion(); editor.focus(); } diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts index 96a8e6549b9c..62e037868ce0 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/text-editor-action.model.ts @@ -148,15 +148,6 @@ export abstract class TextEditorAction implements Disposable { return text.startsWith(openDelimiter) && text.endsWith(closeDelimiter) && text.length >= openDelimiter.length + closeDelimiter.length; } - /** - * Types the given text in the editor at the current cursor position. You can use this e.g. to trigger a suggestion. - * @param editor The editor to type the text in. - * @param text The text to type. - */ - typeText(editor: TextEditor, text: string): void { - editor.typeText(text); - } - /** * Replaces the text at the current selection with the given text. If there is no selection, the text is inserted at the current cursor position. * @param editor The editor to replace the text in. From 51a4ff0b602b49151f458e652437d166096d7525 Mon Sep 17 00:00:00 2001 From: Paul Rangger <48455539+PaRangger@users.noreply.github.com> Date: Sat, 12 Oct 2024 00:02:28 +0200 Subject: [PATCH 2/6] Communication: Add profile pictures to channel member overview (#9450) --- .../artemis/core/dto/UserPublicInfoDTO.java | 14 +++++++++++++- src/main/webapp/app/core/user/user.model.ts | 1 + .../course-wide-search.component.html | 4 ++-- .../conversation-member-row.component.html | 14 +++++++++++++- .../conversation-member-row.component.scss | 19 +++++++++++++++++++ .../conversation-member-row.component.ts | 12 ++++++++++-- src/main/webapp/i18n/de/metis.json | 4 +++- src/main/webapp/i18n/en/metis.json | 4 +++- .../course-wide-search.component.spec.ts | 4 +++- .../conversation-member-row.component.spec.ts | 6 ++++++ 10 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserPublicInfoDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserPublicInfoDTO.java index a6da8966dfc5..f84bf9e0819a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserPublicInfoDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserPublicInfoDTO.java @@ -25,6 +25,8 @@ public class UserPublicInfoDTO { private String lastName; + private String imageUrl; + private Boolean isInstructor; private Boolean isEditor; @@ -43,6 +45,7 @@ public UserPublicInfoDTO(User user) { this.name = user.getName(); this.firstName = user.getFirstName(); this.lastName = user.getLastName(); + this.imageUrl = user.getImageUrl(); } /** @@ -101,6 +104,14 @@ public void setLastName(String lastName) { this.lastName = lastName; } + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + public Boolean getIsInstructor() { return isInstructor; } @@ -152,6 +163,7 @@ public int hashCode() { @Override public String toString() { return "UserPublicInfoDTO{" + "id=" + id + ", login='" + login + '\'' + ", name='" + name + '\'' + ", firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' - + ", isInstructor=" + isInstructor + ", isEditor=" + isEditor + ", isTeachingAssistant=" + isTeachingAssistant + ", isStudent=" + isStudent + '}'; + + ", imageUrl='" + imageUrl + '\'' + ", isInstructor=" + isInstructor + ", isEditor=" + isEditor + ", isTeachingAssistant=" + isTeachingAssistant + ", isStudent=" + + isStudent + '}'; } } diff --git a/src/main/webapp/app/core/user/user.model.ts b/src/main/webapp/app/core/user/user.model.ts index 816cf4fc9a9c..b55ff839fd54 100644 --- a/src/main/webapp/app/core/user/user.model.ts +++ b/src/main/webapp/app/core/user/user.model.ts @@ -66,6 +66,7 @@ export class UserPublicInfoDTO { public firstName?: string; public lastName?: string; public email?: string; + public imageUrl?: string; public isInstructor?: boolean; public isEditor?: boolean; public isTeachingAssistant?: boolean; diff --git a/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html b/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html index f4a255b49422..aa3b35159b88 100644 --- a/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html +++ b/src/main/webapp/app/overview/course-conversations/course-wide-search/course-wide-search.component.html @@ -3,9 +3,9 @@

@if (!courseWideSearchConfig.searchTerm) { - All Messages + } @else { - Search Results for "{{ courseWideSearchConfig.searchTerm }}" + }

diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.html b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.html index a481df162d51..47eff1257e65 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.html +++ b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.html @@ -1,11 +1,23 @@ @if (activeConversation && course) {
- + @if (userImageUrl) { + + } @else { + {{ userInitials }} + } @if (isChannel(activeConversation) && conversationMember?.isChannelModerator) { } {{ userLabel }} + @if (!conversationMember.isStudent) { + + }
@if (canBeRemovedFromConversation || canBeGrantedChannelModeratorRole || canBeRevokedChannelModeratorRole) { diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.scss b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.scss index 28814b8391f5..a2f66745bed6 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.scss +++ b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.scss @@ -1,3 +1,5 @@ +$profile-picture-height: 2rem; + .conversation-member-row { min-height: 3rem; @@ -14,4 +16,21 @@ .dropdown-toggle::after { content: none; } + + .conversation-member-row-default-profile-picture { + font-size: 0.8rem; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .conversation-member-row-profile-picture, + .conversation-member-row-default-profile-picture { + width: $profile-picture-height; + height: $profile-picture-height; + max-width: $profile-picture-height; + max-height: $profile-picture-height; + background-color: var(--gray-400); + color: var(--white); + } } diff --git a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.ts b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.ts index 5044543d4878..39a712c64424 100644 --- a/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.ts +++ b/src/main/webapp/app/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.ts @@ -1,5 +1,5 @@ import { Component, EventEmitter, HostBinding, Input, OnDestroy, OnInit, Output } from '@angular/core'; -import { faChalkboardTeacher, faEllipsis, faUser, faUserCheck, faUserGear } from '@fortawesome/free-solid-svg-icons'; +import { faEllipsis, faUser, faUserCheck, faUserGear, faUserGraduate } from '@fortawesome/free-solid-svg-icons'; import { User } from 'app/core/user/user.model'; import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; import { AccountService } from 'app/core/auth/account.service'; @@ -20,6 +20,8 @@ import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { getAsGroupChatDTO, isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model'; import { GroupChatService } from 'app/shared/metis/conversations/group-chat.service'; import { catchError } from 'rxjs/operators'; +import { getBackgroundColorHue } from 'app/utils/color.utils'; +import { getInitialsFromString } from 'app/utils/text.utils'; @Component({ // eslint-disable-next-line @angular-eslint/component-selector @@ -56,6 +58,9 @@ export class ConversationMemberRowComponent implements OnInit, OnDestroy { canBeRevokedChannelModeratorRole = false; userLabel: string; + userImageUrl: string | undefined; + userDefaultPictureHue: string; + userInitials: string; // icons userIcon: IconProp = faUser; userTooltip = ''; @@ -88,7 +93,10 @@ export class ConversationMemberRowComponent implements OnInit, OnDestroy { this.isCreator = true; } + this.userImageUrl = this.conversationMember.imageUrl; this.userLabel = getUserLabel(this.conversationMember); + this.userInitials = getInitialsFromString(this.conversationMember.name ?? 'NA'); + this.userDefaultPictureHue = getBackgroundColorHue(this.conversationMember.id ? this.conversationMember.id.toString() : 'default'); this.setUserAuthorityIconAndTooltip(); // the creator of a channel can not be removed from the channel this.canBeRemovedFromConversation = !this.isCurrentUser && this.canRemoveUsersFromConversation(this.activeConversation); @@ -242,7 +250,7 @@ export class ConversationMemberRowComponent implements OnInit, OnDestroy { const toolTipTranslationPath = 'artemisApp.metis.userAuthorityTooltips.'; // highest authority is displayed if (this.conversationMember.isInstructor) { - this.userIcon = faChalkboardTeacher; + this.userIcon = faUserGraduate; this.userTooltip = this.translateService.instant(toolTipTranslationPath + 'instructor'); } else if (this.conversationMember.isEditor || this.conversationMember.isTeachingAssistant) { this.userIcon = faUserCheck; diff --git a/src/main/webapp/i18n/de/metis.json b/src/main/webapp/i18n/de/metis.json index 5100a48cd2e4..8a669a9c8c8c 100644 --- a/src/main/webapp/i18n/de/metis.json +++ b/src/main/webapp/i18n/de/metis.json @@ -90,7 +90,9 @@ "TECH_SUPPORT": "Technische Hilfe", "ORGANIZATION": "Organisation", "RANDOM": "Sonstiges", - "ANNOUNCEMENT": "Ankündigung" + "ANNOUNCEMENT": "Ankündigung", + "allPublicMessages": "Alle öffentlichen Nachrichten", + "searchResults": "Suchergebnisse für {{ search }}" }, "post": { "context": "Kontext", diff --git a/src/main/webapp/i18n/en/metis.json b/src/main/webapp/i18n/en/metis.json index bd0cc952c805..9a3ff0977f2b 100644 --- a/src/main/webapp/i18n/en/metis.json +++ b/src/main/webapp/i18n/en/metis.json @@ -90,7 +90,9 @@ "TECH_SUPPORT": "Tech Support", "ORGANIZATION": "Organization", "RANDOM": "Random", - "ANNOUNCEMENT": "Announcement" + "ANNOUNCEMENT": "Announcement", + "allPublicMessages": "All Public Messages", + "searchResults": "Search Results for '{{ search }}'" }, "post": { "context": "Context", diff --git a/src/test/javascript/spec/component/overview/course-conversations/course-wide-search.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/course-wide-search.component.spec.ts index 969c54184708..288ed74a8c86 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/course-wide-search.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/course-wide-search.component.spec.ts @@ -14,11 +14,12 @@ import { BehaviorSubject } from 'rxjs'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { MessageInlineInputComponent } from 'app/shared/metis/message/message-inline-input/message-inline-input.component'; import { PostCreateEditModalComponent } from 'app/shared/metis/posting-create-edit-modal/post-create-edit-modal/post-create-edit-modal.component'; -import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; +import { MockComponent, MockDirective, MockPipe, MockProvider } from 'ng-mocks'; import { PostSortCriterion, SortDirection } from 'app/shared/metis/metis.util'; import { metisExamChannelDTO, metisExerciseChannelDTO, metisGeneralChannelDTO, metisLectureChannelDTO } from '../../../helpers/sample/metis-sample-data'; import { getElement } from '../../../helpers/utils/general.utils'; import { NgbTooltipMocksModule } from '../../../helpers/mocks/directive/ngbTooltipMocks.module'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; @Directive({ // eslint-disable-next-line @angular-eslint/directive-selector @@ -68,6 +69,7 @@ describe('CourseWideSearchComponent', () => { MockComponent(PostingThreadComponent), MockComponent(MessageInlineInputComponent), MockComponent(PostCreateEditModalComponent), + MockDirective(TranslateDirective), ], providers: [MockProvider(MetisConversationService), MockProvider(MetisService), MockProvider(NgbModal)], }).compileComponents(); diff --git a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.spec.ts index e58af8239cc1..133fc64978b9 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/dialogs/conversation-detail-dialog/tabs/conversation-members/conversation-member-row/conversation-member-row.component.spec.ts @@ -22,6 +22,7 @@ import { of } from 'rxjs'; import { isGroupChatDTO } from 'app/entities/metis/conversation/group-chat.model'; import { By } from '@angular/platform-browser'; import { NgbDropdownMocksModule } from '../../../../../../../../helpers/mocks/directive/ngbDropdownMocks.module'; +import { getElement } from '../../../../../../../../helpers/utils/general.utils'; const memberTemplate = { id: 1, @@ -167,6 +168,11 @@ examples.forEach((activeConversation) => { } })); + it('should display default profile picture', () => { + fixture.detectChanges(); + expect(getElement(fixture.debugElement, '.conversation-member-row-default-profile-picture')).not.toBeNull(); + }); + function checkGrantModeratorButton(shouldExist: boolean) { const grantModeratorRoleButton = fixture.debugElement.query(By.css('.grant-moderator')); if (shouldExist) { From 61eda321df24f9b5f6360c69c46a2a63cac7cf51 Mon Sep 17 00:00:00 2001 From: Dmytro Polityka <33299157+undernagruzez@users.noreply.github.com> Date: Sat, 12 Oct 2024 09:56:31 +0200 Subject: [PATCH 3/6] Programming exercises: Improve preliminary AI feedback (#9324) --- .../aet/artemis/assessment/domain/Result.java | 2 +- .../aet/artemis/exercise/domain/Exercise.java | 5 +- .../artemis/exercise/domain/Submission.java | 9 +- .../exercise/web/ParticipationResource.java | 14 +- .../domain/ProgrammingExercise.java | 3 +- ...mingExerciseCodeReviewFeedbackService.java | 27 +-- src/main/webapp/app/entities/result.model.ts | 18 -- ...code-editor-student-container.component.ts | 3 +- ...exercise-trigger-build-button.component.ts | 5 +- .../code-editor-actions.component.html | 3 + .../actions/code-editor-actions.component.ts | 4 +- .../shared/code-editor/code-editor.module.ts | 2 + .../code-editor-container.component.html | 1 + .../utils/programming-exercise.utils.ts | 6 +- .../assessment-progress-label.component.ts | 4 +- ...exercise-assessment-dashboard.component.ts | 4 +- .../exercise-scores.component.ts | 4 +- .../shared/feedback/feedback.component.html | 14 +- .../participation/participation.utils.ts | 12 +- .../shared/result/result.component.html | 20 +- .../shared/result/result.component.ts | 13 +- .../exercises/shared/result/result.service.ts | 13 +- .../exercises/shared/result/result.utils.ts | 47 ++-- .../result/updating-result.component.ts | 15 +- .../course-exercise-details.component.ts | 5 +- .../exercise-buttons.module.ts | 3 +- ...ise-details-student-actions.component.html | 26 +- ...rcise-details-student-actions.component.ts | 32 +-- .../request-feedback-button.component.html | 39 +++ .../request-feedback-button.component.ts | 117 +++++++++ src/main/webapp/i18n/de/exercise.json | 4 +- src/main/webapp/i18n/de/result.json | 3 +- src/main/webapp/i18n/en/exercise.json | 2 +- src/main/webapp/i18n/en/result.json | 1 + .../ParticipationIntegrationTest.java | 109 ++++----- .../exam-navigation-sidebar.component.spec.ts | 3 +- .../component/exercises/shared/result.spec.ts | 26 +- .../request-feedback-button.component.spec.ts | 228 ++++++++++++++++++ .../component/shared/result.component.spec.ts | 1 - .../spec/component/utils/result.utils.spec.ts | 16 +- .../code-editor-container.integration.spec.ts | 2 + .../spec/service/result.service.spec.ts | 6 +- 42 files changed, 644 insertions(+), 227 deletions(-) create mode 100644 src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.html create mode 100644 src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.ts create mode 100644 src/test/javascript/spec/component/overview/exercise-details/request-feedback-button/request-feedback-button.component.spec.ts diff --git a/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java b/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java index cc14d7a35e34..77c01c6fae19 100644 --- a/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java +++ b/src/main/java/de/tum/cit/aet/artemis/assessment/domain/Result.java @@ -629,7 +629,7 @@ public boolean isAutomatic() { * @return true if the result is an automatic AI Athena result */ @JsonIgnore - public boolean isAthenaAutomatic() { + public boolean isAthenaBased() { return AssessmentType.AUTOMATIC_ATHENA == assessmentType; } diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java index 7503427a81fc..b25eb7ab154d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Exercise.java @@ -562,8 +562,9 @@ public Submission findLatestSubmissionWithRatedResultWithCompletionDate(Particip boolean ratedOrPractice = Boolean.TRUE.equals(result.isRated()) || participation.isPracticeMode(); boolean noProgrammingAndAssessmentOver = !isProgrammingExercise && isAssessmentOver; // For programming exercises we check that the assessment due date has passed (if set) for manual results otherwise we always show the automatic result - boolean programmingAfterAssessmentOrAutomatic = isProgrammingExercise && ((result.isManual() && isAssessmentOver) || result.isAutomatic()); - if (ratedOrPractice && (noProgrammingAndAssessmentOver || programmingAfterAssessmentOrAutomatic)) { + boolean programmingAfterAssessmentOrAutomaticOrAthena = isProgrammingExercise + && ((result.isManual() && isAssessmentOver) || result.isAutomatic() || result.isAthenaBased()); + if (ratedOrPractice && (noProgrammingAndAssessmentOver || programmingAfterAssessmentOrAutomaticOrAthena)) { // take the first found result that fulfills the above requirements // or // take newer results and thus disregard older ones diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Submission.java b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Submission.java index 326507d47dd4..304c206938b8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Submission.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/domain/Submission.java @@ -162,7 +162,7 @@ public Result getResultForCorrectionRound(int correctionRound) { */ @NotNull private List filterNonAutomaticResults() { - return results.stream().filter(result -> result == null || !(result.isAutomatic() || result.isAthenaAutomatic())).toList(); + return results.stream().filter(result -> result == null || !(result.isAutomatic() || result.isAthenaBased())).toList(); } /** @@ -188,8 +188,7 @@ public boolean hasResultForCorrectionRound(int correctionRound) { */ @JsonIgnore public void removeAutomaticResults() { - this.results = this.results.stream().filter(result -> result == null || !(result.isAutomatic() || result.isAthenaAutomatic())) - .collect(Collectors.toCollection(ArrayList::new)); + this.results = this.results.stream().filter(result -> result == null || !(result.isAutomatic() || result.isAthenaBased())).collect(Collectors.toCollection(ArrayList::new)); } /** @@ -214,7 +213,7 @@ public List getResults() { @JsonIgnore public List getManualResults() { - return results.stream().filter(result -> result != null && !result.isAutomatic() && !result.isAthenaAutomatic()).collect(Collectors.toCollection(ArrayList::new)); + return results.stream().filter(result -> result != null && !result.isAutomatic() && !result.isAthenaBased()).collect(Collectors.toCollection(ArrayList::new)); } /** @@ -224,7 +223,7 @@ public List getManualResults() { */ @JsonIgnore public List getNonAthenaResults() { - return results.stream().filter(result -> result != null && !result.isAthenaAutomatic()).collect(Collectors.toCollection(ArrayList::new)); + return results.stream().filter(result -> result != null && !result.isAthenaBased()).collect(Collectors.toCollection(ArrayList::new)); } /** diff --git a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java index c6cdc6ee1730..898b4456de07 100644 --- a/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/exercise/web/ParticipationResource.java @@ -20,7 +20,6 @@ import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotNull; -import org.apache.velocity.exception.ResourceNotFoundException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -382,7 +381,7 @@ private ResponseEntity handleExerciseFeedbackRequest(Exerc throw new BadRequestAlertException("Not intended for the use in exams", "participation", "preconditions not met"); } if (exercise.getDueDate() != null && now().isAfter(exercise.getDueDate())) { - throw new BadRequestAlertException("The due date is over", "participation", "preconditions not met"); + throw new BadRequestAlertException("The due date is over", "participation", "feedbackRequestAfterDueDate", true); } if (exercise instanceof ProgrammingExercise) { ((ProgrammingExercise) exercise).validateSettingsForFeedbackRequest(); @@ -393,7 +392,7 @@ private ResponseEntity handleExerciseFeedbackRequest(Exerc StudentParticipation participation = (exercise instanceof ProgrammingExercise) ? programmingExerciseParticipationService.findStudentParticipationByExerciseAndStudentId(exercise, principal.getName()) : studentParticipationRepository.findByExerciseIdAndStudentLogin(exercise.getId(), principal.getName()) - .orElseThrow(() -> new ResourceNotFoundException("Participation not found")); + .orElseThrow(() -> new BadRequestAlertException("Submission not found", "participation", "noSubmissionExists", true)); checkAccessPermissionOwner(participation, user); participation = studentParticipationRepository.findByIdWithResultsElseThrow(participation.getId()); @@ -406,15 +405,14 @@ private ResponseEntity handleExerciseFeedbackRequest(Exerc } else if (exercise instanceof ProgrammingExercise) { if (participation.findLatestLegalResult() == null) { - throw new BadRequestAlertException("User has not reached the conditions to submit a feedback request", "participation", "preconditions not met"); + throw new BadRequestAlertException("You need to submit at least once and have the build results", "participation", "noSubmissionExists", true); } } // Check if feedback has already been requested - var currentDate = now(); - var participationIndividualDueDate = participation.getIndividualDueDate(); - if (participationIndividualDueDate != null && currentDate.isAfter(participationIndividualDueDate)) { - throw new BadRequestAlertException("Request has already been sent", "participation", "already sent"); + var latestResult = participation.findLatestResult(); + if (latestResult != null && latestResult.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA && latestResult.getCompletionDate().isAfter(now())) { + throw new BadRequestAlertException("Request has already been sent", "participation", "feedbackRequestAlreadySent", true); } // Process feedback request diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExercise.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExercise.java index df7911670a22..c2a4666c7c1b 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExercise.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingExercise.java @@ -712,8 +712,7 @@ private boolean checkForRatedAndAssessedResult(Result result) { * @return true if the result is manual and the assessment is over, or it is an automatic result, false otherwise */ private boolean checkForAssessedResult(Result result) { - return result.getCompletionDate() != null - && ((result.isManual() && ExerciseDateService.isAfterAssessmentDueDate(this)) || result.isAutomatic() || result.isAthenaAutomatic()); + return result.getCompletionDate() != null && ((result.isManual() && ExerciseDateService.isAfterAssessmentDueDate(this)) || result.isAutomatic() || result.isAthenaBased()); } @Override diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java index 8c92446d22d0..935a3412b10e 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseCodeReviewFeedbackService.java @@ -4,6 +4,7 @@ import static java.time.ZonedDateTime.now; import java.time.ZonedDateTime; +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -11,6 +12,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; @@ -59,6 +61,9 @@ public class ProgrammingExerciseCodeReviewFeedbackService { private final ProgrammingMessagingService programmingMessagingService; + @Value("${artemis.athena.allowed-feedback-attempts:20}") + private int allowedFeedbackAttempts; + public ProgrammingExerciseCodeReviewFeedbackService(GroupNotificationService groupNotificationService, Optional athenaFeedbackSuggestionsService, SubmissionService submissionService, ResultService resultService, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ResultRepository resultRepository, @@ -111,14 +116,14 @@ public void generateAutomaticNonGradedFeedback(ProgrammingExerciseStudentPartici var submissionOptional = programmingExerciseParticipationService.findProgrammingExerciseParticipationWithLatestSubmissionAndResult(participation.getId()) .findLatestSubmission(); if (submissionOptional.isEmpty()) { - throw new BadRequestAlertException("No legal submissions found", "submission", "noSubmission"); + throw new BadRequestAlertException("No legal submissions found", "submission", "noSubmissionExists"); } var submission = submissionOptional.get(); // save result and transmit it over websockets to notify the client about the status var automaticResult = this.submissionService.saveNewEmptyResult(submission); automaticResult.setAssessmentType(AssessmentType.AUTOMATIC_ATHENA); - automaticResult.setRated(false); + automaticResult.setRated(true); // we want to use this feedback to give the grade in the future automaticResult.setScore(100.0); automaticResult.setSuccessful(null); automaticResult.setCompletionDate(ZonedDateTime.now().plusMinutes(5)); // we do not want to show dates without a completion date, but we want the students to know their @@ -127,7 +132,6 @@ public void generateAutomaticNonGradedFeedback(ProgrammingExerciseStudentPartici try { - setIndividualDueDateAndLockRepository(participation, programmingExercise, false); this.programmingMessagingService.notifyUserAboutNewResult(automaticResult, participation); // now the client should be able to see new result @@ -158,9 +162,10 @@ public void generateAutomaticNonGradedFeedback(ProgrammingExerciseStudentPartici feedback.setDetailText(individualFeedbackItem.description()); feedback.setHasLongFeedbackText(false); feedback.setType(FeedbackType.AUTOMATIC); - feedback.setCredits(0.0); + feedback.setCredits(individualFeedbackItem.credits()); return feedback; - }).toList(); + }).sorted(Comparator.comparing(Feedback::getCredits, Comparator.nullsLast(Comparator.naturalOrder()))).toList(); + ; automaticResult.setSuccessful(true); automaticResult.setCompletionDate(ZonedDateTime.now()); @@ -176,9 +181,6 @@ public void generateAutomaticNonGradedFeedback(ProgrammingExerciseStudentPartici this.resultRepository.save(automaticResult); this.programmingMessagingService.notifyUserAboutNewResult(automaticResult, participation); } - finally { - unlockRepository(participation, programmingExercise); - } } /** @@ -225,15 +227,10 @@ private void checkRateLimitOrThrow(ProgrammingExerciseStudentParticipation parti List athenaResults = participation.getResults().stream().filter(result -> result.getAssessmentType() == AssessmentType.AUTOMATIC_ATHENA).toList(); - long countOfAthenaResultsInProcessOrSuccessful = athenaResults.stream().filter(result -> result.isSuccessful() == null || result.isSuccessful() == Boolean.TRUE).count(); - long countOfSuccessfulRequests = athenaResults.stream().filter(result -> result.isSuccessful() == Boolean.TRUE).count(); - if (countOfAthenaResultsInProcessOrSuccessful >= 3) { - throw new BadRequestAlertException("Cannot send additional AI feedback requests now. Try again later!", "participation", "preconditions not met"); - } - if (countOfSuccessfulRequests >= 20) { - throw new BadRequestAlertException("Maximum number of AI feedback requests reached.", "participation", "preconditions not met"); + if (countOfSuccessfulRequests >= this.allowedFeedbackAttempts) { + throw new BadRequestAlertException("Maximum number of AI feedback requests reached.", "participation", "maxAthenaResultsReached", true); } } } diff --git a/src/main/webapp/app/entities/result.model.ts b/src/main/webapp/app/entities/result.model.ts index d6c2f96adaaa..47fff80fda31 100644 --- a/src/main/webapp/app/entities/result.model.ts +++ b/src/main/webapp/app/entities/result.model.ts @@ -39,24 +39,6 @@ export class Result implements BaseEntity { this.successful = false; // default value } - /** - * Checks whether the result is a manual result. A manual result can be from type MANUAL or SEMI_AUTOMATIC - * - * @return true if the result is a manual result - */ - public static isManualResult(that: Result): boolean { - return that.assessmentType === AssessmentType.MANUAL || that.assessmentType === AssessmentType.SEMI_AUTOMATIC; - } - - /** - * Checks whether the result is generated by Athena AI. - * - * @return true if the result is an automatic Athena AI result - */ - public static isAthenaAIResult(that: Result): boolean { - return that.assessmentType === AssessmentType.AUTOMATIC_ATHENA; - } - /** * Checks whether the given result has an assessment note that is not empty. * @param that the result of which the presence of an assessment note is being checked diff --git a/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.ts b/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.ts index 34aa3e0b0d5a..ad6ed37728d2 100644 --- a/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.ts +++ b/src/main/webapp/app/exercises/programming/participate/code-editor-student-container.component.ts @@ -26,6 +26,7 @@ import { ExerciseHint } from 'app/entities/hestia/exercise-hint.model'; import { ExerciseHintService } from 'app/exercises/shared/exercise-hint/shared/exercise-hint.service'; import { HttpResponse } from '@angular/common/http'; import { AlertService } from 'app/core/util/alert.service'; +import { isManualResult as isManualResultFunction } from 'app/exercises/shared/result/result.utils'; @Component({ selector: 'jhi-code-editor-student', @@ -148,7 +149,7 @@ export class CodeEditorStudentContainerComponent implements OnInit, OnDestroy { let hasTutorFeedback = false; if (this.latestResult) { // latest result is the first element of results, see loadParticipationWithLatestResult - isManualResult = Result.isManualResult(this.latestResult); + isManualResult = isManualResultFunction(this.latestResult); if (isManualResult) { hasTutorFeedback = this.latestResult.feedbacks!.some((feedback) => feedback.type === FeedbackType.MANUAL); } diff --git a/src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-trigger-build-button.component.ts b/src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-trigger-build-button.component.ts index 52089b28c991..1431ad0bfe38 100644 --- a/src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-trigger-build-button.component.ts +++ b/src/main/webapp/app/exercises/programming/shared/actions/programming-exercise-trigger-build-button.component.ts @@ -13,6 +13,7 @@ import { SubmissionType } from 'app/entities/submission.model'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; import { AlertService } from 'app/core/util/alert.service'; import { hasParticipationChanged } from 'app/exercises/shared/participation/participation.utils'; +import { isManualResult } from 'app/exercises/shared/result/result.utils'; /** * Component for triggering a build for the CURRENT submission of the student (does not create a new commit!). @@ -60,7 +61,7 @@ export abstract class ProgrammingExerciseTriggerBuildButtonComponent implements if (hasDueDatePassed(this.exercise)) { // If the last result was manual, the instructor might not want to override it with a new automatic result. const newestResult = !!this.participation.results && head(orderBy(this.participation.results, ['id'], ['desc'])); - this.lastResultIsManual = !!newestResult && Result.isManualResult(newestResult); + this.lastResultIsManual = !!newestResult && isManualResult(newestResult); } // We can trigger the build only if the participation is active (has build plan), if the build plan was archived (new build plan will be created) // or the due date is over. @@ -126,7 +127,7 @@ export abstract class ProgrammingExerciseTriggerBuildButtonComponent implements .pipe( filter((result) => !!result), tap((result: Result) => { - this.lastResultIsManual = !!result && Result.isManualResult(result); + this.lastResultIsManual = !!result && isManualResult(result); }), ) .subscribe(); diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/actions/code-editor-actions.component.html b/src/main/webapp/app/exercises/programming/shared/code-editor/actions/code-editor-actions.component.html index 29e3ded8363c..d0186a25665e 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/actions/code-editor-actions.component.html +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/actions/code-editor-actions.component.html @@ -1,3 +1,6 @@ +@if (!!participation()?.exercise) { + +} @if (commitState === CommitState.CONFLICT) {
diff --git a/src/main/webapp/app/exercises/programming/shared/utils/programming-exercise.utils.ts b/src/main/webapp/app/exercises/programming/shared/utils/programming-exercise.utils.ts index 5e95bb66dbac..262fcb9e30a7 100644 --- a/src/main/webapp/app/exercises/programming/shared/utils/programming-exercise.utils.ts +++ b/src/main/webapp/app/exercises/programming/shared/utils/programming-exercise.utils.ts @@ -7,6 +7,7 @@ import { SubmissionType } from 'app/entities/submission.model'; import { ProgrammingExerciseStudentParticipation } from 'app/entities/participation/programming-exercise-student-participation.model'; import { AssessmentType } from 'app/entities/assessment-type.model'; import { isPracticeMode } from 'app/entities/participation/student-participation.model'; +import { isAIResultAndFailed, isAIResultAndIsBeingProcessed, isAIResultAndProcessed, isAIResultAndTimedOut } from 'app/exercises/shared/result/result.utils'; export const createBuildPlanUrl = (template: string, projectKey: string, buildPlanId: string): string | undefined => { if (template && projectKey && buildPlanId) { @@ -59,7 +60,10 @@ export const isResultPreliminary = (latestResult: Result, programmingExercise?: if (!programmingExercise) { return false; } - if (latestResult.assessmentType === AssessmentType.AUTOMATIC_ATHENA) { + if (isAIResultAndProcessed(latestResult)) { + return true; + } + if (isAIResultAndIsBeingProcessed(latestResult) || isAIResultAndTimedOut(latestResult) || isAIResultAndFailed(latestResult)) { return false; } if (latestResult.participation?.type === ParticipationType.PROGRAMMING && isPracticeMode(latestResult.participation)) { diff --git a/src/main/webapp/app/exercises/shared/assessment-progress-label/assessment-progress-label.component.ts b/src/main/webapp/app/exercises/shared/assessment-progress-label/assessment-progress-label.component.ts index 75f9243432c3..254a3b2a5f82 100644 --- a/src/main/webapp/app/exercises/shared/assessment-progress-label/assessment-progress-label.component.ts +++ b/src/main/webapp/app/exercises/shared/assessment-progress-label/assessment-progress-label.component.ts @@ -1,6 +1,6 @@ import { Component, Input, OnChanges } from '@angular/core'; import { Submission, getLatestSubmissionResult } from 'app/entities/submission.model'; -import { Result } from 'app/entities/result.model'; +import { isManualResult } from 'app/exercises/shared/result/result.utils'; @Component({ selector: 'jhi-assessment-progress-label', @@ -14,7 +14,7 @@ export class AssessmentProgressLabelComponent implements OnChanges { ngOnChanges() { this.numberAssessedSubmissions = this.submissions.filter((submission) => { const result = getLatestSubmissionResult(submission); - return result?.rated && Result.isManualResult(result) && result?.completionDate; + return result?.rated && isManualResult(result) && result?.completionDate; }).length; } } diff --git a/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.ts b/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.ts index a5bffcbe5575..63a23747c1f9 100644 --- a/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.ts +++ b/src/main/webapp/app/exercises/shared/dashboards/tutor/exercise-assessment-dashboard.component.ts @@ -42,12 +42,12 @@ import { ArtemisNavigationUtilService, getLinkToSubmissionAssessment } from 'app import { AssessmentType } from 'app/entities/assessment-type.model'; import { LegendPosition } from '@swimlane/ngx-charts'; import { AssessmentDashboardInformationEntry } from 'app/course/dashboards/assessment-dashboard/assessment-dashboard-information.component'; -import { Result } from 'app/entities/result.model'; import dayjs from 'dayjs/esm'; import { faCheckCircle, faExclamationTriangle, faFolderOpen, faListAlt, faQuestionCircle, faSort, faSpinner } from '@fortawesome/free-solid-svg-icons'; import { GraphColors } from 'app/entities/statistics.model'; import { PROFILE_LOCALVC } from 'app/app.constants'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { isManualResult } from 'app/exercises/shared/result/result.utils'; export interface ExampleSubmissionQueryParams { readOnly?: boolean; @@ -640,7 +640,7 @@ export class ExerciseAssessmentDashboardComponent implements OnInit { */ calculateSubmissionStatusIsDraft(submission: Submission, correctionRound = 0): boolean { const tmpResult = submission.results?.[correctionRound]; - return !(tmpResult?.completionDate && Result.isManualResult(tmpResult)); + return !(tmpResult?.completionDate && isManualResult(tmpResult)); } /** diff --git a/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts b/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts index c2642528bc00..dbc3b28d2e83 100644 --- a/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts +++ b/src/main/webapp/app/exercises/shared/exercise-scores/exercise-scores.component.ts @@ -13,7 +13,6 @@ import { areManualResultsAllowed } from 'app/exercises/shared/exercise/exercise. import { ResultService } from 'app/exercises/shared/result/result.service'; import { Exercise, ExerciseType } from 'app/entities/exercise.model'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; -import { Result } from 'app/entities/result.model'; import { ProgrammingSubmission } from 'app/entities/programming/programming-submission.model'; import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; import { AssessmentType } from 'app/entities/assessment-type.model'; @@ -27,6 +26,7 @@ import dayjs from 'dayjs/esm'; import { ExerciseCacheService } from 'app/exercises/shared/exercise/exercise-cache.service'; import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'; import { PROFILE_LOCALVC } from 'app/app.constants'; +import { isManualResult } from 'app/exercises/shared/result/result.utils'; /** * Filter properties for a result @@ -229,7 +229,7 @@ export class ExerciseScoresComponent implements OnInit, OnDestroy { case FilterProp.BUILD_FAILED: return !!(participation.submissions?.[0] && (participation.submissions?.[0] as ProgrammingSubmission).buildFailed); case FilterProp.MANUAL: - return !!latestResult && Result.isManualResult(latestResult); + return !!latestResult && isManualResult(latestResult); case FilterProp.AUTOMATIC: return latestResult?.assessmentType === AssessmentType.AUTOMATIC; case FilterProp.LOCKED: 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 a0de0676f7dc..23ecac9676b2 100644 --- a/src/main/webapp/app/exercises/shared/feedback/feedback.component.html +++ b/src/main/webapp/app/exercises/shared/feedback/feedback.component.html @@ -119,11 +119,15 @@

{{ 'artemisApp.result.preliminary' | artemisTranslate | uppercase }}
- @if (exercise?.assessmentType !== AssessmentType.AUTOMATIC) { -

- } - @if (exercise?.assessmentType === AssessmentType.AUTOMATIC) { -

+ @if (result?.assessmentType === AssessmentType.AUTOMATIC_ATHENA) { +

+ } @else { + @if (exercise?.assessmentType !== AssessmentType.AUTOMATIC) { +

+ } + @if (exercise?.assessmentType === AssessmentType.AUTOMATIC) { +

+ } }

} diff --git a/src/main/webapp/app/exercises/shared/participation/participation.utils.ts b/src/main/webapp/app/exercises/shared/participation/participation.utils.ts index 5fc349f22b27..d931734c6407 100644 --- a/src/main/webapp/app/exercises/shared/participation/participation.utils.ts +++ b/src/main/webapp/app/exercises/shared/participation/participation.utils.ts @@ -6,6 +6,7 @@ import dayjs from 'dayjs/esm'; import { StudentParticipation } from 'app/entities/participation/student-participation.model'; import { Result } from 'app/entities/result.model'; import { orderBy as _orderBy } from 'lodash-es'; +import { isAIResultAndIsBeingProcessed } from 'app/exercises/shared/result/result.utils'; /** * Check if the participation has changed. @@ -102,7 +103,11 @@ export const isParticipationInDueTime = (participation: Participation, exercise: * @param participation * @param showUngradedResults */ -export function getLatestResultOfStudentParticipation(participation: StudentParticipation | undefined, showUngradedResults: boolean): Result | undefined { +export function getLatestResultOfStudentParticipation( + participation: StudentParticipation | undefined, + showUngradedResults: boolean, + showAthenaPreliminaryFeedback: boolean = false, +): Result | undefined { if (!participation) { return undefined; } @@ -111,8 +116,11 @@ export function getLatestResultOfStudentParticipation(participation: StudentPart if (participation.results) { participation.results = _orderBy(participation.results, 'completionDate', 'desc'); } + // The latest result is the first rated result in the sorted array (=newest) or any result if the option is active to show ungraded results. - const latestResult = participation.results?.find(({ rated }) => showUngradedResults || rated === true); + const latestResult = participation.results?.find( + (result) => showUngradedResults || result.rated === true || (showAthenaPreliminaryFeedback && isAIResultAndIsBeingProcessed(result)), + ); // Make sure that the participation result is connected to the newest result. return latestResult ? { ...latestResult, participation: participation } : undefined; } diff --git a/src/main/webapp/app/exercises/shared/result/result.component.html b/src/main/webapp/app/exercises/shared/result/result.component.html index 2dfd17685054..911320c183df 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.html +++ b/src/main/webapp/app/exercises/shared/result/result.component.html @@ -12,6 +12,9 @@ } @case (ResultTemplateStatus.FEEDBACK_GENERATION_FAILED) { @if (result) { + @if (showIcon) { + + } {{ resultString }} @@ -20,17 +23,16 @@ } } @case (ResultTemplateStatus.IS_GENERATING_FEEDBACK) { - @if (result) { - - - - {{ resultString }} - - - } + + + + } @case (ResultTemplateStatus.FEEDBACK_GENERATION_TIMED_OUT) { @if (result) { + @if (showIcon) { + + } {{ resultString }} @@ -59,7 +61,7 @@ } @if (!isInSidebarCard) { - ({{ result!.completionDate | artemisTimeAgo }}) + ({{ result!.completionDate | artemisTimeAgo }} ) } @if (hasBuildArtifact() && participation.type === ParticipationType.PROGRAMMING) { 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 3415021e11c7..f9edf80994d9 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.ts +++ b/src/main/webapp/app/exercises/shared/result/result.component.ts @@ -1,6 +1,13 @@ import { Component, Input, OnChanges, OnDestroy, OnInit, Optional, SimpleChanges } from '@angular/core'; import { ParticipationService } from 'app/exercises/shared/participation/participation.service'; -import { MissingResultInformation, ResultTemplateStatus, evaluateTemplateStatus, getResultIconClass, getTextColorClass } from 'app/exercises/shared/result/result.utils'; +import { + MissingResultInformation, + ResultTemplateStatus, + evaluateTemplateStatus, + getResultIconClass, + getTextColorClass, + isAthenaAIResult, +} from 'app/exercises/shared/result/result.utils'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; @@ -190,7 +197,7 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { this.resultString = this.resultService.getResultString(this.result, this.exercise, this.short); } else if ( this.result && - ((this.result.score !== undefined && (this.result.rated || this.result.rated == undefined || this.showUngradedResults)) || Result.isAthenaAIResult(this.result)) + ((this.result.score !== undefined && (this.result.rated || this.result.rated == undefined || this.showUngradedResults)) || isAthenaAIResult(this.result)) ) { this.textColorClass = getTextColorClass(this.result, this.templateStatus); this.resultIconClass = getResultIconClass(this.result, this.templateStatus); @@ -230,7 +237,7 @@ export class ResultComponent implements OnInit, OnChanges, OnDestroy { return 'artemisApp.result.resultString.automaticAIFeedbackTimedOutTooltip'; } else if (this.templateStatus === ResultTemplateStatus.IS_GENERATING_FEEDBACK) { return 'artemisApp.result.resultString.automaticAIFeedbackInProgressTooltip'; - } else if (this.templateStatus === ResultTemplateStatus.HAS_RESULT && Result.isAthenaAIResult(this.result)) { + } else if (this.templateStatus === ResultTemplateStatus.HAS_RESULT && isAthenaAIResult(this.result)) { return 'artemisApp.result.resultString.automaticAIFeedbackSuccessfulTooltip'; } } diff --git a/src/main/webapp/app/exercises/shared/result/result.service.ts b/src/main/webapp/app/exercises/shared/result/result.service.ts index 2fd62aa574b7..ea4c9eaaca4c 100644 --- a/src/main/webapp/app/exercises/shared/result/result.service.ts +++ b/src/main/webapp/app/exercises/shared/result/result.service.ts @@ -24,6 +24,7 @@ import { isAIResultAndIsBeingProcessed, isAIResultAndProcessed, isAIResultAndTimedOut, + isAthenaAIResult, isStudentParticipation, } from 'app/exercises/shared/result/result.utils'; import { CsvDownloadService } from 'app/shared/util/CsvDownloadService'; @@ -94,7 +95,7 @@ export class ResultService implements IResultService { const relativeScore = roundValueSpecifiedByCourseSettings(result.score!, getCourseFromExercise(exercise)); const points = roundValueSpecifiedByCourseSettings((result.score! * exercise.maxPoints!) / 100, getCourseFromExercise(exercise)); if (exercise.type !== ExerciseType.PROGRAMMING) { - if (Result.isAthenaAIResult(result)) { + if (isAthenaAIResult(result)) { return this.getResultStringNonProgrammingExerciseWithAIFeedback(result, relativeScore, points, short); } return this.getResultStringNonProgrammingExercise(relativeScore, points, short); @@ -112,7 +113,7 @@ export class ResultService implements IResultService { */ private getResultStringNonProgrammingExerciseWithAIFeedback(result: Result, relativeScore: number, points: number, short: boolean | undefined): string { let aiFeedbackMessage: string = ''; - if (result && Result.isAthenaAIResult(result) && result.successful === undefined) { + if (result && isAthenaAIResult(result) && result.successful === undefined) { return this.translateService.instant('artemisApp.result.resultString.automaticAIFeedbackInProgress'); } aiFeedbackMessage = this.getResultStringNonProgrammingExercise(relativeScore, points, short); @@ -149,9 +150,7 @@ export class ResultService implements IResultService { */ private getResultStringProgrammingExercise(result: Result, exercise: ProgrammingExercise, relativeScore: number, points: number, short: boolean | undefined): string { let buildAndTestMessage: string; - if (result.submission && (result.submission as ProgrammingSubmission).buildFailed) { - buildAndTestMessage = this.translateService.instant('artemisApp.result.resultString.buildFailed'); - } else if (isAIResultAndFailed(result)) { + if (isAIResultAndFailed(result)) { buildAndTestMessage = this.translateService.instant('artemisApp.result.resultString.automaticAIFeedbackFailed'); } else if (isAIResultAndIsBeingProcessed(result)) { buildAndTestMessage = this.translateService.instant('artemisApp.result.resultString.automaticAIFeedbackInProgress'); @@ -159,6 +158,8 @@ export class ResultService implements IResultService { buildAndTestMessage = this.translateService.instant('artemisApp.result.resultString.automaticAIFeedbackTimedOut'); } else if (isAIResultAndProcessed(result)) { buildAndTestMessage = this.translateService.instant('artemisApp.result.resultString.automaticAIFeedbackSuccessful'); + } else if (result.submission && (result.submission as ProgrammingSubmission).buildFailed) { + buildAndTestMessage = this.translateService.instant('artemisApp.result.resultString.buildFailed'); } else if (!result.testCaseCount) { buildAndTestMessage = this.translateService.instant('artemisApp.result.resultString.buildSuccessfulNoTests'); } else { @@ -187,7 +188,7 @@ export class ResultService implements IResultService { * @param short flag that indicates if the resultString should use the short format */ private getBaseResultStringProgrammingExercise(result: Result, relativeScore: number, points: number, buildAndTestMessage: string, short: boolean | undefined): string { - if (Result.isAthenaAIResult(result)) { + if (isAthenaAIResult(result)) { return buildAndTestMessage; } if (short) { diff --git a/src/main/webapp/app/exercises/shared/result/result.utils.ts b/src/main/webapp/app/exercises/shared/result/result.utils.ts index be0126c54db9..cee9c6dbb07f 100644 --- a/src/main/webapp/app/exercises/shared/result/result.utils.ts +++ b/src/main/webapp/app/exercises/shared/result/result.utils.ts @@ -119,20 +119,29 @@ export const getUnreferencedFeedback = (feedbacks: Feedback[] | undefined): Feed return feedbacks ? feedbacks.filter((feedbackElement) => !feedbackElement.reference && feedbackElement.type === FeedbackType.MANUAL_UNREFERENCED) : undefined; }; -export function isAIResultAndFailed(result: Result | undefined) { - return result && Result.isAthenaAIResult(result) && result.successful === false; +export function isAIResultAndFailed(result: Result | undefined): boolean { + return (result && isAthenaAIResult(result) && result.successful === false) ?? false; } -export function isAIResultAndTimedOut(result: Result | undefined) { - return result && Result.isAthenaAIResult(result) && result.successful === undefined && result.completionDate && dayjs().isAfter(result.completionDate); +export function isAIResultAndTimedOut(result: Result | undefined): boolean { + return (result && isAthenaAIResult(result) && result.successful === undefined && result.completionDate && dayjs().isAfter(result.completionDate)) ?? false; } -export function isAIResultAndProcessed(result: Result | undefined) { - return result && Result.isAthenaAIResult(result) && result.successful === true; +export function isAIResultAndProcessed(result: Result | undefined): boolean { + return (result && isAthenaAIResult(result) && result.successful === true) ?? false; } -export function isAIResultAndIsBeingProcessed(result: Result | undefined) { - return result && Result.isAthenaAIResult(result) && result.successful === undefined && result.completionDate && dayjs().isSameOrBefore(result.completionDate); +export function isAIResultAndIsBeingProcessed(result: Result | undefined): boolean { + return (result && isAthenaAIResult(result) && result.successful === undefined && result.completionDate && dayjs().isSameOrBefore(result.completionDate)) ?? false; +} + +/** + * Checks whether the result is generated by Athena AI. + * + * @return true if the result is an automatic Athena AI result + */ +export function isAthenaAIResult(result: Result): boolean { + return result.assessmentType === AssessmentType.AUTOMATIC_ATHENA; } export const evaluateTemplateStatus = ( @@ -248,9 +257,12 @@ export const getTextColorClass = (result: Result | undefined, templateStatus: Re } if (result.assessmentType === AssessmentType.AUTOMATIC_ATHENA) { - if (result.successful == undefined) { + if (isAIResultAndIsBeingProcessed(result)) { return 'text-primary'; } + if (isAIResultAndFailed(result)) { + return 'text-danger'; + } return 'text-secondary'; } @@ -258,11 +270,11 @@ export const getTextColorClass = (result: Result | undefined, templateStatus: Re return 'result-late'; } - if (isBuildFailedAndResultIsAutomatic(result) || isAIResultAndFailed(result)) { + if (isBuildFailedAndResultIsAutomatic(result)) { return 'text-danger'; } - if (resultIsPreliminary(result) || isAIResultAndIsBeingProcessed(result) || isAIResultAndTimedOut(result)) { + if (resultIsPreliminary(result)) { return 'text-secondary'; } @@ -294,18 +306,19 @@ export const getResultIconClass = (result: Result | undefined, templateStatus: R return faQuestionCircle; } - if (result.assessmentType === AssessmentType.AUTOMATIC_ATHENA) { - if (result.successful === undefined) { - return faCircleNotch; - } - return faQuestionCircle; + if (isAIResultAndProcessed(result)) { + return faCheckCircle; } if (isBuildFailedAndResultIsAutomatic(result) || isAIResultAndFailed(result)) { return faTimesCircle; } - if (resultIsPreliminary(result) || isAIResultAndTimedOut(result) || isAIResultAndIsBeingProcessed(result)) { + if (isAIResultAndIsBeingProcessed(result)) { + return faCircleNotch; + } + + if (resultIsPreliminary(result) || isAIResultAndTimedOut(result)) { return faQuestionCircle; } diff --git a/src/main/webapp/app/exercises/shared/result/updating-result.component.ts b/src/main/webapp/app/exercises/shared/result/updating-result.component.ts index 55cde780b0ec..640b02f38bff 100644 --- a/src/main/webapp/app/exercises/shared/result/updating-result.component.ts +++ b/src/main/webapp/app/exercises/shared/result/updating-result.component.ts @@ -13,7 +13,7 @@ import { StudentParticipation } from 'app/entities/participation/student-partici import { Result } from 'app/entities/result.model'; import { getExerciseDueDate } from 'app/exercises/shared/exercise/exercise.utils'; import { getLatestResultOfStudentParticipation, hasParticipationChanged } from 'app/exercises/shared/participation/participation.utils'; -import { MissingResultInformation } from 'app/exercises/shared/result/result.utils'; +import { MissingResultInformation, isAIResultAndIsBeingProcessed, isAthenaAIResult } from 'app/exercises/shared/result/result.utils'; import { convertDateFromServer } from 'app/utils/date.utils'; /** @@ -59,7 +59,7 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { */ ngOnChanges(changes: SimpleChanges) { if (hasParticipationChanged(changes)) { - this.result = getLatestResultOfStudentParticipation(this.participation, this.showUngradedResults); + this.result = getLatestResultOfStudentParticipation(this.participation, this.showUngradedResults, true); this.missingResultInfo = MissingResultInformation.NONE; this.subscribeForNewResults(); @@ -101,10 +101,17 @@ export class UpdatingResultComponent implements OnChanges, OnDestroy { // Ignore initial null result of subscription filter((result) => !!result), // Ignore ungraded results if ungraded results are supposed to be ignored. - filter((result: Result) => this.showUngradedResults || result.rated === true), + // If the result is a preliminary feedback(being generated), show it + filter((result: Result) => this.showUngradedResults || result.rated === true || isAthenaAIResult(result)), map((result) => ({ ...result, completionDate: convertDateFromServer(result.completionDate), participation: this.participation })), tap((result) => { - this.result = result; + if ((isAthenaAIResult(result) && isAIResultAndIsBeingProcessed(result)) || result.rated) { + this.result = result; + } else if (result.rated === false && this.showUngradedResults) { + this.result = result; + } else { + this.result = getLatestResultOfStudentParticipation(this.participation, this.showUngradedResults, false); + } this.onParticipationChange.emit(); if (result) { this.showResult.emit(); diff --git a/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts b/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts index 62a1b6fa7429..4790d0b24bf9 100644 --- a/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts +++ b/src/main/webapp/app/overview/exercise-details/course-exercise-details.component.ts @@ -198,7 +198,6 @@ export class CourseExerciseDetailsComponent extends AbstractScienceComponent imp this.exerciseCategories = this.exercise.categories ?? []; this.allowComplaintsForAutomaticAssessments = false; this.plagiarismCaseInfo = newExerciseDetails.plagiarismCaseInfo; - if (this.exercise.type === ExerciseType.PROGRAMMING) { const programmingExercise = this.exercise as ProgrammingExercise; const isAfterDateForComplaint = @@ -243,7 +242,7 @@ export class CourseExerciseDetailsComponent extends AbstractScienceComponent imp private filterUnfinishedResults(participations?: StudentParticipation[]) { participations?.forEach((participation: Participation) => { if (participation.results) { - participation.results = participation.results.filter((result: Result) => result.completionDate && result.successful !== undefined); + participation.results = participation.results.filter((result: Result) => result.completionDate); } }); } @@ -254,7 +253,7 @@ export class CourseExerciseDetailsComponent extends AbstractScienceComponent imp this.sortedHistoryResults = this.studentParticipations .flatMap((participation) => participation.results ?? []) .sort(this.resultSortFunction) - .filter((result) => !(result.assessmentType === AssessmentType.AUTOMATIC_ATHENA && result.successful == undefined)); + .filter((result) => !(result.assessmentType === AssessmentType.AUTOMATIC_ATHENA && dayjs().isBefore(result.completionDate))); } } diff --git a/src/main/webapp/app/overview/exercise-details/exercise-buttons.module.ts b/src/main/webapp/app/overview/exercise-details/exercise-buttons.module.ts index 1b36ab7e6f5d..7b6aab3d5f7c 100644 --- a/src/main/webapp/app/overview/exercise-details/exercise-buttons.module.ts +++ b/src/main/webapp/app/overview/exercise-details/exercise-buttons.module.ts @@ -6,9 +6,10 @@ import { OrionExerciseDetailsStudentActionsComponent } from 'app/orion/participa import { ExerciseDetailsStudentActionsComponent } from 'app/overview/exercise-details/exercise-details-student-actions.component'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; import { ArtemisSharedPipesModule } from 'app/shared/pipes/shared-pipes.module'; +import { RequestFeedbackButtonComponent } from 'app/overview/exercise-details/request-feedback-button/request-feedback-button.component'; @NgModule({ - imports: [ArtemisSharedModule, ArtemisSharedComponentModule, ArtemisSharedPipesModule, OrionModule, FeatureToggleModule], + imports: [ArtemisSharedModule, ArtemisSharedComponentModule, ArtemisSharedPipesModule, OrionModule, FeatureToggleModule, RequestFeedbackButtonComponent], declarations: [ExerciseDetailsStudentActionsComponent, OrionExerciseDetailsStudentActionsComponent], exports: [ExerciseDetailsStudentActionsComponent, OrionExerciseDetailsStudentActionsComponent], }) diff --git a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html index 53a99d1f440a..6e2df76cbef9 100644 --- a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html +++ b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html @@ -135,30 +135,8 @@ } - @if (exercise.allowFeedbackRequests) { - @if (athenaEnabled) { - - - Send automatic feedback request - - } @else { - - - Send manual feedback request - - } + @if (exercise.allowFeedbackRequests && gradedParticipation && exercise.type === ExerciseType.PROGRAMMING) { + } } diff --git a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts index fcdf131a87c4..2991bac3355f 100644 --- a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts +++ b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.ts @@ -110,6 +110,7 @@ export class ExerciseDetailsStudentActionsComponent implements OnInit, OnChanges this.profileService.getProfileInfo().subscribe((profileInfo) => { this.localVCEnabled = profileInfo.activeProfiles?.includes(PROFILE_LOCALVC); this.athenaEnabled = profileInfo.activeProfiles?.includes(PROFILE_ATHENA); + // The online IDE is only available with correct SpringProfile and if it's enabled for this exercise if (profileInfo.activeProfiles?.includes(PROFILE_THEIA) && this.programmingExercise) { this.theiaEnabled = true; @@ -257,6 +258,7 @@ export class ExerciseDetailsStudentActionsComponent implements OnInit, OnChanges }); } + // TODO remove this method once support of the button component is implemented for text and modeling exercises requestFeedback() { if (!this.assureConditionsSatisfied()) return; if (this.exercise.type === ExerciseType.PROGRAMMING) { @@ -341,6 +343,7 @@ export class ExerciseDetailsStudentActionsComponent implements OnInit, OnChanges * 3. There is no already pending feedback request. * @returns {boolean} `true` if all conditions are satisfied, otherwise `false`. */ + // TODO remove this method once support of the button component is implemented for text and modeling exercises assureConditionsSatisfied(): boolean { this.updateParticipations(); if (this.exercise.type === ExerciseType.PROGRAMMING) { @@ -378,7 +381,7 @@ export class ExerciseDetailsStudentActionsComponent implements OnInit, OnChanges } } - if (this.hasAthenaResultForlatestSubmission()) { + if (this.hasAthenaResultForLatestSubmission()) { const submitFirstWarning = this.translateService.instant('artemisApp.exercise.submissionAlreadyHasAthenaResult'); this.alertService.warning(submitFirstWarning); return false; @@ -386,29 +389,14 @@ export class ExerciseDetailsStudentActionsComponent implements OnInit, OnChanges return true; } - hasAthenaResultForlatestSubmission(): boolean { + hasAthenaResultForLatestSubmission(): boolean { if (this.gradedParticipation?.submissions && this.gradedParticipation?.results) { - const sortedSubmissions = this.gradedParticipation.submissions.slice().sort((a, b) => { - const dateA = this.getDateValue(a.submissionDate) ?? -Infinity; - const dateB = this.getDateValue(b.submissionDate) ?? -Infinity; - return dateB - dateA; - }); - - return this.gradedParticipation.results.some((result) => result.submission?.id === sortedSubmissions[0]?.id); + // submissions.results is always undefined so this is necessary + return ( + this.gradedParticipation.submissions.last()?.id === + this.gradedParticipation?.results.filter((result) => result.assessmentType == AssessmentType.AUTOMATIC_ATHENA).first()?.submission?.id + ); } return false; } - - private getDateValue = (date: any): number => { - if (dayjs.isDayjs(date)) { - return date.valueOf(); - } - if (date instanceof Date) { - return date.valueOf(); - } - if (typeof date === 'string') { - return new Date(date).valueOf(); - } - return -Infinity; // fallback for null, undefined, or invalid dates - }; } diff --git a/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.html b/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.html new file mode 100644 index 000000000000..6d6addcc2b84 --- /dev/null +++ b/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.html @@ -0,0 +1,39 @@ +@if (!isExamExercise) { + @if (athenaEnabled) { + @if (exercise().type === ExerciseType.TEXT) { + + } @else { + + + + + } + } @else { + + + + + } +} diff --git a/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.ts b/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.ts new file mode 100644 index 000000000000..b9aecaec56d5 --- /dev/null +++ b/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.ts @@ -0,0 +1,117 @@ +import { Component, OnInit, inject, input, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faPenSquare } from '@fortawesome/free-solid-svg-icons'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { PROFILE_ATHENA } from 'app/app.constants'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; +import { Exercise, ExerciseType } from 'app/entities/exercise.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { CourseExerciseService } from 'app/exercises/shared/course-exercises/course-exercise.service'; +import { AssessmentType } from 'app/entities/assessment-type.model'; +import { TranslateService } from '@ngx-translate/core'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { isExamExercise } from 'app/shared/util/utils'; +import { ExerciseDetailsType, ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { ParticipationService } from 'app/exercises/shared/participation/participation.service'; + +@Component({ + selector: 'jhi-request-feedback-button', + standalone: true, + imports: [CommonModule, ArtemisSharedCommonModule, NgbTooltipModule, FontAwesomeModule], + templateUrl: './request-feedback-button.component.html', +}) +export class RequestFeedbackButtonComponent implements OnInit { + faPenSquare = faPenSquare; + athenaEnabled = false; + isExamExercise: boolean; + participation?: StudentParticipation; + + isGeneratingFeedback = input(); + smallButtons = input(false); + exercise = input.required(); + generatingFeedback = output(); + + private feedbackSent = false; + private profileService = inject(ProfileService); + private alertService = inject(AlertService); + private courseExerciseService = inject(CourseExerciseService); + private translateService = inject(TranslateService); + private exerciseService = inject(ExerciseService); + private participationService = inject(ParticipationService); + + protected readonly ExerciseType = ExerciseType; + + ngOnInit() { + this.profileService.getProfileInfo().subscribe((profileInfo) => { + this.athenaEnabled = profileInfo.activeProfiles?.includes(PROFILE_ATHENA); + }); + this.isExamExercise = isExamExercise(this.exercise()); + if (this.isExamExercise || !this.exercise().id) { + return; + } + this.updateParticipation(); + } + + private updateParticipation() { + if (this.exercise().id) { + this.exerciseService.getExerciseDetails(this.exercise().id!).subscribe({ + next: (exerciseResponse: HttpResponse) => { + this.participation = this.participationService.getSpecificStudentParticipation(exerciseResponse.body!.exercise.studentParticipations ?? [], false); + }, + error: (error: HttpErrorResponse) => { + this.alertService.error(`artemisApp.${error.error.entityName}.errors.${error.error.errorKey}`); + }, + }); + } + } + + requestFeedback() { + if (!this.assureConditionsSatisfied()) { + return; + } + + this.courseExerciseService.requestFeedback(this.exercise().id!).subscribe({ + next: (participation: StudentParticipation) => { + if (participation) { + this.generatingFeedback.emit(); + this.feedbackSent = true; + this.alertService.success('artemisApp.exercise.feedbackRequestSent'); + } + }, + error: (error: HttpErrorResponse) => { + this.alertService.error(`artemisApp.exercise.${error.error.errorKey}`); + }, + }); + } + + /** + * Checks if the conditions for requesting automatic non-graded feedback are satisfied. + * The student can request automatic non-graded feedback under the following conditions: + * 1. They have a graded submission. + * 2. The deadline for the exercise has not been exceeded. + * 3. There is no already pending feedback request. + * @returns {boolean} `true` if all conditions are satisfied, otherwise `false`. + */ + assureConditionsSatisfied(): boolean { + if (this.exercise().type === ExerciseType.PROGRAMMING || !this.hasAthenaResultForLatestSubmission()) { + return true; + } + const submitFirstWarning = this.translateService.instant('artemisApp.exercise.submissionAlreadyHasAthenaResult'); + this.alertService.warning(submitFirstWarning); + return false; + } + + hasAthenaResultForLatestSubmission(): boolean { + if (this.participation?.submissions && this.participation?.results) { + // submissions.results is always undefined so this is neccessary + return ( + this.participation.submissions?.last()?.id === + this.participation.results?.filter((result) => result.assessmentType == AssessmentType.AUTOMATIC_ATHENA).first()?.submission?.id + ); + } + return false; + } +} diff --git a/src/main/webapp/i18n/de/exercise.json b/src/main/webapp/i18n/de/exercise.json index fb698572ee34..3aee88e253c5 100644 --- a/src/main/webapp/i18n/de/exercise.json +++ b/src/main/webapp/i18n/de/exercise.json @@ -168,8 +168,8 @@ "resumeProgrammingExercise": "Die Aufgabe wurde wieder aufgenommen. Du kannst nun weiterarbeiten!", "feedbackRequestSent": "Deine Feedbackanfrage wurde gesendet.", "feedbackRequestAlreadySent": "Deine Feedbackanfrage wurde bereits gesendet.", - "notEnoughPoints": "Um eine Feedbackanfrage zu senden, brauchst du mindestens eine Abgabe.", - "lockRepositoryWarning": "Dein Repository wird gesperrt. Du kannst erst weiterarbeiten, wenn deine Feedbackanfrage beantwortet wird.", + "noSubmissionExists": "Um eine Feedbackanfrage zu senden, brauchst du mindestens eine Abgabe.", + "lockRepositoryWarning": "Dein Repository wird gesperrt. Du kannst erst weiterarbeiten wenn deine Feedbackanfrage beantwortet wird.", "feedbackRequestAfterDueDate": "Du kannst nach der Abgabefrist keine weiteren Anfragen einreichen.", "maxAthenaResultsReached": "Du hast die maximale Anzahl an KI-Feedbackanfragen erreicht.", "athenaFeedbackSuccessful": "AI-Feedback erfolgreich generiert. Klicke auf das Ergebnis, um Details zu sehen.", diff --git a/src/main/webapp/i18n/de/result.json b/src/main/webapp/i18n/de/result.json index c97ee41d3d33..933db7e69593 100644 --- a/src/main/webapp/i18n/de/result.json +++ b/src/main/webapp/i18n/de/result.json @@ -92,7 +92,8 @@ "preliminary": "vorläufig", "preliminaryTooltip": "Dein Ergebnis ist noch nicht endgültig, weil weitere Tests nach der Einreichungsfrist ausgeführt werden.", "preliminaryTooltipSemiAutomatic": "Dein Ergebnis ist noch nicht endgültig, weil weitere Tests nach der Einreichungsfrist ausgeführt werden oder eine manuelle Bewertung aussteht.", - "codeIssuesTooltip": "Die automatische Codeanalyse hat Code-Issues gefunden.", + "preliminaryTooltipAthena": "Dies ist eine KI-Bewertung. Das tatsächliche Ergebnis kann abweichen.", + "codeIssuesTooltip": "Die automatische Codeanalyse hat Codeissues gefunden.", "noResultDetails": "Keine weiteren Informationen verfügbar für dieses Ergebnis.", "onlyCompilationTested": "Dein Code kompiliert erfolgreich. Derzeit sind keine Testfälle sichtbar.", "chart": { diff --git a/src/main/webapp/i18n/en/exercise.json b/src/main/webapp/i18n/en/exercise.json index 6a06631fc343..f50e61efcbb8 100644 --- a/src/main/webapp/i18n/en/exercise.json +++ b/src/main/webapp/i18n/en/exercise.json @@ -168,7 +168,7 @@ "resumeProgrammingExercise": "The exercise has been resumed. You can now continue working on the exercise!", "feedbackRequestSent": "Your feedback request has been sent.", "feedbackRequestAlreadySent": "Your feedback request has already been sent.", - "notEnoughPoints": "You have to submit your work at least once.", + "noSubmissionExists": "You have to submit your work at least once.", "lockRepositoryWarning": "Your repository will be locked. You can only continue working after you receive an answer.", "feedbackRequestAfterDueDate": "You cannot submit feedback requests after the due date.", "maxAthenaResultsReached": "You have reached the maximum number of AI feedback requests.", diff --git a/src/main/webapp/i18n/en/result.json b/src/main/webapp/i18n/en/result.json index 67bdc0711adb..f8178c0cde1e 100644 --- a/src/main/webapp/i18n/en/result.json +++ b/src/main/webapp/i18n/en/result.json @@ -92,6 +92,7 @@ "preliminary": "preliminary", "preliminaryTooltip": "Your result is not final yet, because more tests will be executed after the due date", "preliminaryTooltipSemiAutomatic": "Your result is not final yet, because more tests will be executed after the due date or a manual assessment will be done.", + "preliminaryTooltipAthena": "This is an AI grading. The actual result may differ", "codeIssuesTooltip": "The automatic code analysis generated some warnings for your code.", "noResultDetails": "No result details available.", "onlyCompilationTested": "Your code compiled successfully. There are currently no tests visible.", diff --git a/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java index 1162e7b3477d..04df504a5dbb 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exercise/participation/ParticipationIntegrationTest.java @@ -3,7 +3,6 @@ import static de.tum.cit.aet.artemis.core.connector.AthenaRequestMockProvider.ATHENA_MODULE_PROGRAMMING_TEST; import static de.tum.cit.aet.artemis.core.util.TestResourceUtils.HalfSecond; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyBoolean; import static org.mockito.Mockito.doNothing; @@ -538,7 +537,51 @@ void requestFeedbackAlreadySent() throws Exception { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void requestFeedbackSuccess_withAthenaSuccess() throws Exception { + void requestProgrammingFeedbackIfARequestAlreadySent_withAthenaSuccess() throws Exception { + + var course = programmingExercise.getCourseViaExerciseGroupOrCourseMember(); + course.setRestrictedAthenaModulesAccess(true); + this.courseRepository.save(course); + + this.programmingExercise.setFeedbackSuggestionModule(ATHENA_MODULE_PROGRAMMING_TEST); + this.exerciseRepository.save(programmingExercise); + + athenaRequestMockProvider.mockGetFeedbackSuggestionsAndExpect("programming"); + + var participation = ParticipationFactory.generateProgrammingExerciseStudentParticipation(InitializationState.INACTIVE, programmingExercise, + userUtilService.getUserByLogin(TEST_PREFIX + "student1")); + + var localRepo = new LocalRepository(defaultBranch); + localRepo.configureRepos("testLocalRepo", "testOriginRepo"); + + participation.setRepositoryUri(ParticipationFactory.getMockFileRepositoryUri(localRepo).getURI().toString()); + participationRepo.save(participation); + + gitService.getDefaultLocalPathOfRepo(participation.getVcsRepositoryUri()); + + Result result1 = participationUtilService.createSubmissionAndResult(participation, 100, false); + Result result2 = participationUtilService.addResultToParticipation(participation, result1.getSubmission()); + result2.setAssessmentType(AssessmentType.AUTOMATIC_ATHENA); + result2.setSuccessful(null); + resultRepository.save(result2); + + request.putWithResponseBody("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, ProgrammingExerciseStudentParticipation.class, HttpStatus.OK); + + verify(programmingMessagingService, timeout(2000).times(2)).notifyUserAboutNewResult(resultCaptor.capture(), any()); + + Result invokedResult = resultCaptor.getAllValues().getFirst(); + assertThat(invokedResult).isNotNull(); + assertThat(invokedResult.getId()).isNotNull(); + assertThat(invokedResult.isSuccessful()).isTrue(); + assertThat(invokedResult.isAthenaBased()).isTrue(); + assertThat(invokedResult.getFeedbacks()).hasSize(1); + + localRepo.resetLocalRepo(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void requestProgrammingFeedbackSuccess_withAthenaSuccess() throws Exception { var course = programmingExercise.getCourseViaExerciseGroupOrCourseMember(); course.setRestrictedAthenaModulesAccess(true); @@ -566,9 +609,6 @@ void requestFeedbackSuccess_withAthenaSuccess() throws Exception { result2.setCompletionDate(ZonedDateTime.now()); resultRepository.save(result2); - doNothing().when(programmingExerciseParticipationService).lockStudentRepositoryAndParticipation(eq(programmingExercise), any()); - doNothing().when(programmingExerciseParticipationService).unlockStudentRepositoryAndParticipation(any()); - request.putWithResponseBody("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, ProgrammingExerciseStudentParticipation.class, HttpStatus.OK); verify(programmingMessagingService, timeout(2000).times(2)).notifyUserAboutNewResult(resultCaptor.capture(), any()); @@ -577,7 +617,7 @@ void requestFeedbackSuccess_withAthenaSuccess() throws Exception { assertThat(invokedResult).isNotNull(); assertThat(invokedResult.getId()).isNotNull(); assertThat(invokedResult.isSuccessful()).isTrue(); - assertThat(invokedResult.isAthenaAutomatic()).isTrue(); + assertThat(invokedResult.isAthenaBased()).isTrue(); assertThat(invokedResult.getFeedbacks()).hasSize(1); localRepo.resetLocalRepo(); @@ -614,7 +654,7 @@ void requestTextFeedbackSuccess_withAthenaSuccess() throws Exception { Result invokedTextResult = resultCaptor.getAllValues().get(1); assertThat(invokedTextResult).isNotNull(); assertThat(invokedTextResult.getId()).isNotNull(); - assertThat(invokedTextResult.isAthenaAutomatic()).isTrue(); + assertThat(invokedTextResult.isAthenaBased()).isTrue(); assertThat(invokedTextResult.getFeedbacks()).hasSize(1); } @@ -649,13 +689,13 @@ void requestModelingFeedbackSuccess_withAthenaSuccess() throws Exception { Result invokedModelingResult = resultCaptor.getAllValues().get(1); assertThat(invokedModelingResult).isNotNull(); assertThat(invokedModelingResult.getId()).isNotNull(); - assertThat(invokedModelingResult.isAthenaAutomatic()).isTrue(); + assertThat(invokedModelingResult.isAthenaBased()).isTrue(); assertThat(invokedModelingResult.getFeedbacks()).hasSize(1); } @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void requestFeedbackSuccess_withAthenaFailure() throws Exception { + void requestProgrammingFeedbackSuccess_withAthenaFailure() throws Exception { var course = programmingExercise.getCourseViaExerciseGroupOrCourseMember(); course.setRestrictedAthenaModulesAccess(true); @@ -682,9 +722,6 @@ void requestFeedbackSuccess_withAthenaFailure() throws Exception { result2.setCompletionDate(ZonedDateTime.now()); resultRepository.save(result2); - doNothing().when(programmingExerciseParticipationService).lockStudentRepositoryAndParticipation(any(), any()); - doNothing().when(programmingExerciseParticipationService).unlockStudentRepositoryAndParticipation(any()); - request.putWithResponseBody("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, ProgrammingExerciseStudentParticipation.class, HttpStatus.OK); verify(programmingMessagingService, timeout(2000).times(2)).notifyUserAboutNewResult(resultCaptor.capture(), any()); @@ -693,7 +730,7 @@ void requestFeedbackSuccess_withAthenaFailure() throws Exception { assertThat(invokedResult).isNotNull(); assertThat(invokedResult.getId()).isNotNull(); assertThat(invokedResult.isSuccessful()).isFalse(); - assertThat(invokedResult.isAthenaAutomatic()).isTrue(); + assertThat(invokedResult.isAthenaBased()).isTrue(); assertThat(invokedResult.getFeedbacks()).hasSize(0); localRepo.resetLocalRepo(); @@ -729,7 +766,7 @@ void requestTextFeedbackSuccess_withAthenaFailure() throws Exception { Result invokedTextResult = resultCaptor.getAllValues().getFirst(); assertThat(invokedTextResult).isNotNull(); - assertThat(invokedTextResult.isAthenaAutomatic()).isTrue(); + assertThat(invokedTextResult.isAthenaBased()).isTrue(); assertThat(invokedTextResult.getFeedbacks()).hasSize(0); } @@ -763,7 +800,7 @@ void requestModelingFeedbackSuccess_withAthenaFailure() throws Exception { Result invokedModelingResult = resultCaptor.getAllValues().getFirst(); assertThat(invokedModelingResult).isNotNull(); - assertThat(invokedModelingResult.isAthenaAutomatic()).isTrue(); + assertThat(invokedModelingResult.isAthenaBased()).isTrue(); assertThat(invokedModelingResult.getFeedbacks()).hasSize(0); } @@ -1615,7 +1652,7 @@ void whenFeedbackRequestedAndDeadlinePassed_thenFail() throws Exception { result.setCompletionDate(ZonedDateTime.now()); resultRepository.save(result); - request.putAndExpectError("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, HttpStatus.BAD_REQUEST, "preconditions not met"); + request.putAndExpectError("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, HttpStatus.BAD_REQUEST, "feedbackRequestAfterDueDate"); localRepo.resetLocalRepo(); } @@ -1643,50 +1680,14 @@ void whenFeedbackRequestedAndRateLimitExceeded_thenFail() throws Exception { resultRepository.save(result); // generate 5 athena results - for (int i = 0; i < 5; i++) { - var athenaResult = ParticipationFactory.generateResult(false, 100).participation(participation); - athenaResult.setCompletionDate(ZonedDateTime.now()); - athenaResult.setAssessmentType(AssessmentType.AUTOMATIC_ATHENA); - resultRepository.save(athenaResult); - } - - request.putAndExpectError("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, HttpStatus.BAD_REQUEST, "preconditions not met"); - - localRepo.resetLocalRepo(); - } - - @Test - @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void whenFeedbackRequestedAndRateLimitStillUnknownDueRequestsInProgress_thenFail() throws Exception { - - programmingExercise.setDueDate(ZonedDateTime.now().plusDays(100)); - programmingExercise = exerciseRepository.save(programmingExercise); - - var participation = ParticipationFactory.generateProgrammingExerciseStudentParticipation(InitializationState.INACTIVE, programmingExercise, - userUtilService.getUserByLogin(TEST_PREFIX + "student1")); - - var localRepo = new LocalRepository(defaultBranch); - localRepo.configureRepos("testLocalRepo", "testOriginRepo"); - - participation.setRepositoryUri(ParticipationFactory.getMockFileRepositoryUri(localRepo).getURI().toString()); - participationRepo.save(participation); - - gitService.getDefaultLocalPathOfRepo(participation.getVcsRepositoryUri()); - - var result = ParticipationFactory.generateResult(false, 100).participation(participation); - result.setCompletionDate(ZonedDateTime.now()); - resultRepository.save(result); - - // generate 5 athena results - for (int i = 0; i < 5; i++) { + for (int i = 0; i < 20; i++) { var athenaResult = ParticipationFactory.generateResult(false, 100).participation(participation); athenaResult.setCompletionDate(ZonedDateTime.now()); athenaResult.setAssessmentType(AssessmentType.AUTOMATIC_ATHENA); - athenaResult.setSuccessful(null); resultRepository.save(athenaResult); } - request.putAndExpectError("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, HttpStatus.BAD_REQUEST, "preconditions not met"); + request.putAndExpectError("/api/exercises/" + programmingExercise.getId() + "/request-feedback", null, HttpStatus.BAD_REQUEST, "maxAthenaResultsReached"); localRepo.resetLocalRepo(); } diff --git a/src/test/javascript/spec/component/exam/participate/exam-navigation-sidebar.component.spec.ts b/src/test/javascript/spec/component/exam/participate/exam-navigation-sidebar.component.spec.ts index 45ce49fc2b4d..3a9102c72e8e 100644 --- a/src/test/javascript/spec/component/exam/participate/exam-navigation-sidebar.component.spec.ts +++ b/src/test/javascript/spec/component/exam/participate/exam-navigation-sidebar.component.spec.ts @@ -20,6 +20,7 @@ import { TranslateService } from '@ngx-translate/core'; import { facSaveSuccess, facSaveWarning } from 'src/main/webapp/content/icons/icons'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { ExamLiveEventsButtonComponent } from 'app/exam/participate/events/exam-live-events-button.component'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; describe('ExamNavigationSidebarComponent', () => { let fixture: ComponentFixture; @@ -33,7 +34,7 @@ describe('ExamNavigationSidebarComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, TranslateTestingModule, MockModule(NgbTooltipModule)], + imports: [ArtemisTestModule, TranslateTestingModule, MockModule(NgbTooltipModule), MockModule(ArtemisSharedCommonModule)], declarations: [ExamNavigationSidebarComponent, MockComponent(ExamTimerComponent), MockComponent(ExamLiveEventsButtonComponent)], providers: [ ExamParticipationService, diff --git a/src/test/javascript/spec/component/exercises/shared/result.spec.ts b/src/test/javascript/spec/component/exercises/shared/result.spec.ts index 72c2cd1aafca..ff076e80d8c8 100644 --- a/src/test/javascript/spec/component/exercises/shared/result.spec.ts +++ b/src/test/javascript/spec/component/exercises/shared/result.spec.ts @@ -12,7 +12,7 @@ import { TranslateService } from '@ngx-translate/core'; import { cloneDeep } from 'lodash-es'; import { Submission } from 'app/entities/submission.model'; import { ExerciseType } from 'app/entities/exercise.model'; -import { faQuestionCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons'; +import { faCheckCircle, faQuestionCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons'; import { ProgrammingExercise } from 'app/entities/programming/programming-exercise.model'; import { ModelingExercise } from 'app/entities/modeling-exercise.model'; import { ProgrammingExerciseStudentParticipation } from 'app/entities/participation/programming-exercise-student-participation.model'; @@ -141,11 +141,31 @@ describe('ResultComponent', () => { expect(component.result!.participation).toEqual(participation1); expect(component.submission).toEqual(submission1); expect(component.textColorClass).toBe('text-secondary'); - expect(component.resultIconClass).toEqual(faQuestionCircle); + expect(component.resultIconClass).toEqual(faCheckCircle); expect(component.resultString).toBe('artemisApp.result.resultString.short (artemisApp.result.preliminary)'); expect(component.templateStatus).toBe(ResultTemplateStatus.HAS_RESULT); }); + it('should set (automatic athena) results for programming exercise', () => { + const submission1: Submission = { id: 1 }; + const result1: Result = { id: 1, submission: submission1, score: 0.8, assessmentType: AssessmentType.AUTOMATIC_ATHENA, successful: true }; + const result2: Result = { id: 2 }; + const participation1 = cloneDeep(programmingParticipation); + participation1.results = [result1, result2]; + component.participation = participation1; + component.showUngradedResults = true; + + fixture.detectChanges(); + + expect(component.result).toEqual(result1); + expect(component.result!.participation).toEqual(participation1); + expect(component.submission).toEqual(submission1); + expect(component.textColorClass).toBe('text-secondary'); + expect(component.resultIconClass).toEqual(faCheckCircle); + expect(component.resultString).toBe('artemisApp.result.resultString.automaticAIFeedbackSuccessful (artemisApp.result.preliminary)'); + expect(component.templateStatus).toBe(ResultTemplateStatus.HAS_RESULT); + }); + it('should set (automatic athena) results for text exercise', () => { const submission1: Submission = { id: 1 }; const result1: Result = { id: 1, submission: submission1, score: 1, assessmentType: AssessmentType.AUTOMATIC_ATHENA, successful: true }; @@ -161,7 +181,7 @@ describe('ResultComponent', () => { expect(component.result!.participation).toEqual(participation1); expect(component.submission).toEqual(submission1); expect(component.textColorClass).toBe('text-secondary'); - expect(component.resultIconClass).toEqual(faQuestionCircle); + expect(component.resultIconClass).toEqual(faCheckCircle); expect(component.resultString).toBe('artemisApp.result.resultString.short (artemisApp.result.preliminary)'); }); diff --git a/src/test/javascript/spec/component/overview/exercise-details/request-feedback-button/request-feedback-button.component.spec.ts b/src/test/javascript/spec/component/overview/exercise-details/request-feedback-button/request-feedback-button.component.spec.ts new file mode 100644 index 000000000000..94219fdd2420 --- /dev/null +++ b/src/test/javascript/spec/component/overview/exercise-details/request-feedback-button/request-feedback-button.component.spec.ts @@ -0,0 +1,228 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { MockProvider } from 'ng-mocks'; +import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { Observable, of } from 'rxjs'; +import { Exercise, ExerciseType } from 'app/entities/exercise.model'; +import { StudentParticipation } from 'app/entities/participation/student-participation.model'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; +import { AlertService } from 'app/core/util/alert.service'; +import { CourseExerciseService } from 'app/exercises/shared/course-exercises/course-exercise.service'; +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { ExerciseService } from 'app/exercises/shared/exercise/exercise.service'; +import { RequestFeedbackButtonComponent } from 'app/overview/exercise-details/request-feedback-button/request-feedback-button.component'; +import { ArtemisTestModule } from '../../../../test.module'; +import { MockProfileService } from '../../../../helpers/mocks/service/mock-profile.service'; +import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; + +describe('RequestFeedbackButtonComponent', () => { + let component: RequestFeedbackButtonComponent; + let fixture: ComponentFixture; + let debugElement: DebugElement; + let profileService: ProfileService; + let alertService: AlertService; + let courseExerciseService: CourseExerciseService; + let exerciseService: ExerciseService; + + beforeEach(() => { + return TestBed.configureTestingModule({ + imports: [ArtemisTestModule, RequestFeedbackButtonComponent], + providers: [{ provide: ProfileService, useClass: MockProfileService }, MockProvider(HttpClient)], + }) + .compileComponents() + .then(() => { + fixture = TestBed.createComponent(RequestFeedbackButtonComponent); + component = fixture.componentInstance; + debugElement = fixture.debugElement; + courseExerciseService = debugElement.injector.get(CourseExerciseService); + exerciseService = debugElement.injector.get(ExerciseService); + profileService = debugElement.injector.get(ProfileService); + alertService = debugElement.injector.get(AlertService); + }); + }); + + function setAthenaEnabled(enabled: boolean) { + jest.spyOn(profileService, 'getProfileInfo').mockReturnValue(of({ activeProfiles: enabled ? ['athena'] : [] } as ProfileInfo)); + } + + function mockExerciseDetails(exercise: Exercise) { + jest.spyOn(exerciseService, 'getExerciseDetails').mockReturnValue(of(new HttpResponse({ body: { exercise: exercise } }))); + } + + it('should handle errors when requestFeedback fails', fakeAsync(() => { + setAthenaEnabled(true); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: true }], + testRun: false, + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.TEXT, course: undefined, studentParticipations: [participation] } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + mockExerciseDetails(exercise); + + jest.spyOn(courseExerciseService, 'requestFeedback').mockReturnValue( + new Observable((subscriber) => { + subscriber.error({ error: { errorKey: 'someError' } }); + }), + ); + jest.spyOn(alertService, 'error'); + + component.requestFeedback(); + tick(); + + expect(alertService.error).toHaveBeenCalledWith('artemisApp.exercise.someError'); + })); + + it('should display the button when Athena is enabled and it is not an exam exercise', fakeAsync(() => { + setAthenaEnabled(true); + const exercise = { id: 1, type: ExerciseType.TEXT, course: {} } as Exercise; // course undefined means exam exercise + fixture.componentRef.setInput('exercise', exercise); + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + expect(button.nativeElement.disabled).toBeTrue(); + })); + + it('should not display the button when it is an exam exercise', fakeAsync(() => { + setAthenaEnabled(true); + fixture.componentRef.setInput('exercise', { id: 1, type: ExerciseType.TEXT, course: undefined } as Exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + const link = debugElement.query(By.css('a')); + expect(button).toBeNull(); + expect(link).toBeNull(); + })); + + it('should disable the button when participation is missing', fakeAsync(() => { + setAthenaEnabled(true); + const exercise = { id: 1, type: ExerciseType.TEXT, course: {}, studentParticipations: undefined } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + expect(button.nativeElement.disabled).toBeTrue(); + })); + + it('should display the correct button label and style when Athena is enabled', fakeAsync(() => { + setAthenaEnabled(true); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: true }], + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.TEXT, course: {}, studentParticipations: [participation] } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + component.isExamExercise = false; + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + + const span = button.query(By.css('span')); + expect(span.nativeElement.textContent).toContain('artemisApp.exerciseActions.requestAutomaticFeedback'); + })); + + it('should call requestFeedback() when button is clicked', fakeAsync(() => { + setAthenaEnabled(true); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: false }], + testRun: false, + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.PROGRAMMING, studentParticipations: [participation], course: {} } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + jest.spyOn(component, 'requestFeedback'); + jest.spyOn(courseExerciseService, 'requestFeedback').mockReturnValue( + new Observable((subscriber) => { + subscriber.next(); + subscriber.complete(); + }), + ); + + const button = debugElement.query(By.css('a')); + button.nativeElement.click(); + tick(); + + expect(component.requestFeedback).toHaveBeenCalled(); + })); + + it('should show an alert when requestFeedback() is called and conditions are not satisfied', fakeAsync(() => { + setAthenaEnabled(true); + + const exercise = { id: 1, type: ExerciseType.TEXT, course: {} } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + + jest.spyOn(component, 'hasAthenaResultForLatestSubmission').mockReturnValue(true); + jest.spyOn(alertService, 'warning'); + + component.requestFeedback(); + + expect(alertService.warning).toHaveBeenCalled(); + })); + + it('should disable the button if latest submission is not submitted or feedback is generating', fakeAsync(() => { + setAthenaEnabled(true); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: false }], + testRun: false, + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.TEXT, studentParticipations: [participation], course: {} } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + fixture.componentRef.setInput('isGeneratingFeedback', false); + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + expect(button.nativeElement.disabled).toBeTrue(); + })); + + it('should enable the button if latest submission is submitted and feedback is not generating', fakeAsync(() => { + setAthenaEnabled(true); + const participation = { + id: 1, + submissions: [{ id: 1, submitted: true }], + testRun: false, + } as StudentParticipation; + const exercise = { id: 1, type: ExerciseType.TEXT, course: {}, studentParticipations: [participation] } as Exercise; + fixture.componentRef.setInput('exercise', exercise); + fixture.componentRef.setInput('isGeneratingFeedback', false); + mockExerciseDetails(exercise); + + component.ngOnInit(); + tick(); + fixture.detectChanges(); + + const button = debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + expect(button.nativeElement.disabled).toBeFalse(); + })); +}); 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 d72fdc5f3bd2..e013c1e03e58 100644 --- a/src/test/javascript/spec/component/shared/result.component.spec.ts +++ b/src/test/javascript/spec/component/shared/result.component.spec.ts @@ -370,7 +370,6 @@ describe('ResultComponent', () => { it('should use special handling if result is an automatic AI result', () => { comp.result = { ...mockResult, score: 90, assessmentType: AssessmentType.AUTOMATIC_ATHENA }; - jest.spyOn(Result, 'isAthenaAIResult').mockReturnValue(true); comp.evaluate(); diff --git a/src/test/javascript/spec/component/utils/result.utils.spec.ts b/src/test/javascript/spec/component/utils/result.utils.spec.ts index 4796766de234..24303c3e59af 100644 --- a/src/test/javascript/spec/component/utils/result.utils.spec.ts +++ b/src/test/javascript/spec/component/utils/result.utils.spec.ts @@ -15,6 +15,7 @@ import { faCheckCircle, faQuestionCircle, faTimesCircle } from '@fortawesome/fre import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'; import { ExerciseType } from 'app/entities/exercise.model'; import { Result } from 'app/entities/result.model'; +import dayjs from 'dayjs/esm'; describe('ResultUtils', () => { it('should filter out all non unreferenced feedbacks', () => { @@ -69,7 +70,7 @@ describe('ResultUtils', () => { { result: { score: 0, successful: undefined, assessmentType: AssessmentType.AUTOMATIC_ATHENA }, templateStatus: ResultTemplateStatus.IS_GENERATING_FEEDBACK, - expected: 'text-primary', + expected: 'text-secondary', }, { result: { score: 0, successful: true, assessmentType: AssessmentType.AUTOMATIC_ATHENA }, @@ -128,7 +129,12 @@ describe('ResultUtils', () => { expected: faTimesCircle, }, { - result: { feedbacks: [{ type: FeedbackType.AUTOMATIC, text: 'AI result being generated test case' }], assessmentType: AssessmentType.AUTOMATIC_ATHENA }, + result: { + feedbacks: [{ type: FeedbackType.AUTOMATIC, text: 'AI result being generated test case' }], + assessmentType: AssessmentType.AUTOMATIC_ATHENA, + successful: undefined, + completionDate: dayjs().add(5, 'minutes'), + }, templateStatus: ResultTemplateStatus.IS_GENERATING_FEEDBACK, expected: faCircleNotch, }, @@ -138,9 +144,10 @@ describe('ResultUtils', () => { participation: { type: ParticipationType.STUDENT, exercise: { type: ExerciseType.TEXT } }, successful: true, assessmentType: AssessmentType.AUTOMATIC_ATHENA, + completionDate: dayjs().subtract(5, 'minutes'), } as Result, templateStatus: ResultTemplateStatus.HAS_RESULT, - expected: faQuestionCircle, + expected: faCheckCircle, }, { result: { @@ -148,9 +155,10 @@ describe('ResultUtils', () => { participation: { type: ParticipationType.STUDENT, exercise: { type: ExerciseType.TEXT } }, successful: false, assessmentType: AssessmentType.AUTOMATIC_ATHENA, + completionDate: dayjs().subtract(5, 'minutes'), } as Result, templateStatus: ResultTemplateStatus.HAS_RESULT, - expected: faQuestionCircle, + expected: faTimesCircle, }, ])('should correctly determine result icon', ({ result, templateStatus, expected }) => { expect(getResultIconClass(result, templateStatus!)).toBe(expected); diff --git a/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts b/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts index 406dce4f6a5c..d55cc49a6983 100644 --- a/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts +++ b/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts @@ -74,6 +74,7 @@ import { CodeEditorHeaderComponent } from 'app/exercises/programming/shared/code import { AlertService } from 'app/core/util/alert.service'; import { MockResizeObserver } from '../../helpers/mocks/service/mock-resize-observer'; import { CodeEditorMonacoComponent } from 'app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component'; +import { RequestFeedbackButtonComponent } from 'app/overview/exercise-details/request-feedback-button/request-feedback-button.component'; import { MonacoEditorComponent } from '../../../../../main/webapp/app/shared/monaco-editor/monaco-editor.component'; describe('CodeEditorContainerIntegration', () => { @@ -123,6 +124,7 @@ describe('CodeEditorContainerIntegration', () => { TreeviewItemComponent, MockPipe(ArtemisDatePipe), MockComponent(CodeEditorTutorAssessmentInlineFeedbackComponent), + MockComponent(RequestFeedbackButtonComponent), ], providers: [ CodeEditorConflictStateService, diff --git a/src/test/javascript/spec/service/result.service.spec.ts b/src/test/javascript/spec/service/result.service.spec.ts index 7bd887f9cb9e..e6c24cf75850 100644 --- a/src/test/javascript/spec/service/result.service.spec.ts +++ b/src/test/javascript/spec/service/result.service.spec.ts @@ -307,8 +307,10 @@ describe('ResultService', () => { it('should return correct string for Athena non graded successful feedback', () => { programmingExercise.assessmentDueDate = dayjs().subtract(5, 'minutes'); - expect(resultService.getResultString(result6, programmingExercise)).toBe('artemisApp.result.resultString.automaticAIFeedbackSuccessful'); - expect(translateServiceSpy).toHaveBeenCalledOnce(); + expect(resultService.getResultString(result6, programmingExercise)).toBe( + 'artemisApp.result.resultString.automaticAIFeedbackSuccessful (artemisApp.result.preliminary)', + ); + expect(translateServiceSpy).toHaveBeenCalledTimes(2); }); it('should return correct string for Athena non graded unsuccessful feedback', () => { From ced37c15469d9eeba275c227f677206b6332d48f Mon Sep 17 00:00:00 2001 From: Lucas Welscher Date: Sat, 12 Oct 2024 10:26:32 +0200 Subject: [PATCH 4/6] Development: Fix wrong result subscription for exam exercises (#9453) --- .../artemis/core/config/websocket/WebsocketConfiguration.java | 4 +--- .../participate/summary/exam-result-summary.component.html | 1 + .../programming-exam-summary.component.html | 2 +- .../programming-exam-summary.component.ts | 2 ++ 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java index 9163cfb7d7f1..d0c6941cc698 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java @@ -64,7 +64,6 @@ import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider; import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; import de.tum.cit.aet.artemis.exam.repository.ExamRepository; -import de.tum.cit.aet.artemis.exercise.domain.Exercise; import de.tum.cit.aet.artemis.exercise.domain.participation.StudentParticipation; import de.tum.cit.aet.artemis.exercise.repository.ExerciseRepository; import de.tum.cit.aet.artemis.exercise.repository.StudentParticipationRepository; @@ -309,8 +308,7 @@ private boolean allowSubscription(@Nullable Principal principal, String destinat // TODO: Is it right that TAs are not allowed to subscribe to exam exercises? if (exerciseRepository.isExamExercise(exerciseId)) { - Exercise exercise = exerciseRepository.findByIdElseThrow(exerciseId); - return authorizationCheckService.isAtLeastInstructorInCourse(login, exercise.getCourseViaExerciseGroupOrCourseMember().getId()); + return authorizationCheckService.isAtLeastInstructorInExercise(login, exerciseId); } else { return authorizationCheckService.isAtLeastTeachingAssistantInExercise(login, exerciseId); 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 f1017919bfb7..44eea193c092 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 @@ -190,6 +190,7 @@

[resultsPublished]="resultsArePublished" [isPrinting]="isPrinting" [isAfterResultsArePublished]="resultsArePublished" + [instructorView]="instructorView" /> } } 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 c483a168cd8a..2a0df39f1353 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 @@ -48,7 +48,7 @@
@if (exercise.problemStatement) { - + }
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 2243c413644a..5ffdffc38018 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 @@ -39,6 +39,8 @@ export class ProgrammingExamSummaryComponent implements OnInit { @Input() isAfterResultsArePublished?: boolean = false; + @Input() instructorView?: boolean = false; + readonly PROGRAMMING: ExerciseType = ExerciseType.PROGRAMMING; protected readonly AssessmentType = AssessmentType; From 6eaebc2f0f9e3214de22607ebf47b627fddcf928 Mon Sep 17 00:00:00 2001 From: Ole Vester <73833780+ole-ve@users.noreply.github.com> Date: Sat, 12 Oct 2024 10:29:31 +0200 Subject: [PATCH 5/6] Development: Fix issues with server test flakiness (#9417) --- .../PushNotificationDeviceConfiguration.java | 4 +- .../artemis/core/service/ZipFileService.java | 9 +++- .../ExerciseScoresChartIntegrationTest.java | 7 --- .../ParticipantScoreIntegrationTest.java | 7 --- .../ResultListenerIntegrationTest.java | 7 --- .../CourseCompetencyIntegrationTest.java | 2 - .../NotificationScheduleServiceTest.java | 2 - .../SingleUserNotificationServiceTest.java | 47 +++++++++++++------ .../TutorialGroupNotificationServiceTest.java | 5 -- .../service/EmailSummaryServiceTest.java | 3 -- ...DeviceConfigurationCleanupServiceTest.java | 6 +-- .../artemis/core/MetricsIntegrationTest.java | 5 -- .../core/StatisticsIntegrationTest.java | 1 - .../aet/artemis/exam/ExamIntegrationTest.java | 1 - .../ExamParticipationIntegrationTest.java | 1 - .../exam/ExamRegistrationIntegrationTest.java | 1 - .../cit/aet/artemis/exam/ExamStartTest.java | 1 - .../exam/ProgrammingExamIntegrationTest.java | 1 - .../artemis/exam/TestExamIntegrationTest.java | 1 - .../CourseGitlabJenkinsIntegrationTest.java | 1 - ...rogrammingExerciseTestCaseServiceTest.java | 3 +- ...gExerciseGitDiffReportIntegrationTest.java | 5 ++ ...mmingExerciseGitDiffReportServiceTest.java | 5 ++ .../hestia/StructuralTestCaseServiceTest.java | 5 ++ .../TestwiseCoverageReportServiceTest.java | 5 ++ .../BehavioralTestCaseServiceTest.java | 5 ++ ...AbstractLocalCILocalVCIntegrationTest.java | 19 ++++---- .../icl/LocalCIIntegrationTest.java | 12 ++++- .../icl/LocalCIResourceIntegrationTest.java | 10 +++- .../icl/LocalCIResultServiceTest.java | 7 +++ .../icl/LocalVCIntegrationTest.java | 20 +++++++- .../icl/LocalVCLocalCIIntegrationTest.java | 7 +++ .../icl/LocalVCLocalCITestService.java | 14 +++++- .../icl/LocalVCSshIntegrationTest.java | 31 ++++++++++-- .../icl/MultipleHostKeyProviderTest.java | 7 +++ .../quiz/QuizSubmissionIntegrationTest.java | 14 ++++-- .../base/AbstractArtemisIntegrationTest.java | 11 +++++ 37 files changed, 201 insertions(+), 91 deletions(-) diff --git a/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java index c6bfd3384110..b9a911ce6194 100644 --- a/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java +++ b/src/main/java/de/tum/cit/aet/artemis/communication/domain/push_notification/PushNotificationDeviceConfiguration.java @@ -106,7 +106,9 @@ public boolean equals(Object object) { return false; } PushNotificationDeviceConfiguration that = (PushNotificationDeviceConfiguration) object; - return token.equals(that.token) && deviceType == that.deviceType && expirationDate.equals(that.expirationDate) && Arrays.equals(secretKey, that.secretKey) + // Use compareTo rather than equals for dates to ensure timestamps and dates with the same time are considered equal + // This is caused by Java internal design having different classes for Date (java.util) and Timestamp (java.sql) + return token.equals(that.token) && deviceType == that.deviceType && expirationDate.compareTo(that.expirationDate) == 0 && Arrays.equals(secretKey, that.secretKey) && owner.equals(that.owner); } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/service/ZipFileService.java b/src/main/java/de/tum/cit/aet/artemis/core/service/ZipFileService.java index 4d40473c4eb9..5871cd7ed7d4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/service/ZipFileService.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/service/ZipFileService.java @@ -6,6 +6,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Set; import java.util.function.Predicate; import java.util.stream.Stream; import java.util.zip.ZipEntry; @@ -33,6 +34,12 @@ public class ZipFileService { private final FileService fileService; + /** + * Set of file names that should be ignored when zipping. + * This currently only includes the gc.log.lock (garbage collector) file created by JGit in programming repositories. + */ + private static final Set IGNORED_ZIP_FILE_NAMES = Set.of(Path.of("gc.log.lock")); + public ZipFileService(FileService fileService) { this.fileService = fileService; } @@ -113,7 +120,7 @@ private void createZipFileFromPathStream(Path zipFilePath, Stream paths, P if (extraFilter != null) { filteredPaths = filteredPaths.filter(extraFilter); } - filteredPaths.forEach(path -> { + filteredPaths.filter(path -> !IGNORED_ZIP_FILE_NAMES.contains(path)).forEach(path -> { ZipEntry zipEntry = new ZipEntry(pathsRoot.relativize(path).toString()); copyToZipFile(zipOutputStream, path, zipEntry); }); diff --git a/src/test/java/de/tum/cit/aet/artemis/assessment/ExerciseScoresChartIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/assessment/ExerciseScoresChartIntegrationTest.java index fd48d4a5ace5..8782f11f8593 100644 --- a/src/test/java/de/tum/cit/aet/artemis/assessment/ExerciseScoresChartIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/assessment/ExerciseScoresChartIntegrationTest.java @@ -3,10 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -import java.time.Instant; import java.time.ZonedDateTime; import java.util.List; -import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.AfterEach; @@ -15,7 +13,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.util.ReflectionTestUtils; import de.tum.cit.aet.artemis.assessment.repository.ParticipantScoreRepository; import de.tum.cit.aet.artemis.assessment.service.ParticipantScoreScheduleService; @@ -60,11 +57,7 @@ class ExerciseScoresChartIntegrationTest extends AbstractSpringIntegrationIndepe @BeforeEach void setupTestScenario() { - // Prevents the ParticipantScoreScheduleService from scheduling tasks related to prior results - ReflectionTestUtils.setField(participantScoreScheduleService, "lastScheduledRun", Optional.of(Instant.now())); - ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 50; - participantScoreScheduleService.activate(); ZonedDateTime pastTimestamp = ZonedDateTime.now().minusDays(5); userUtilService.addUsers(TEST_PREFIX, 3, 2, 0, 0); Course course = courseUtilService.createCourse(); diff --git a/src/test/java/de/tum/cit/aet/artemis/assessment/ParticipantScoreIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/assessment/ParticipantScoreIntegrationTest.java index 85f521f00be5..1313f5147150 100644 --- a/src/test/java/de/tum/cit/aet/artemis/assessment/ParticipantScoreIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/assessment/ParticipantScoreIntegrationTest.java @@ -3,10 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -import java.time.Instant; import java.time.ZonedDateTime; import java.util.List; -import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.AfterEach; @@ -15,7 +13,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.util.ReflectionTestUtils; import de.tum.cit.aet.artemis.assessment.domain.GradingScale; import de.tum.cit.aet.artemis.assessment.dto.score.ScoreDTO; @@ -98,11 +95,7 @@ class ParticipantScoreIntegrationTest extends AbstractSpringIntegrationLocalCILo @BeforeEach void setupTestScenario() { - // Prevents the ParticipantScoreScheduleService from scheduling tasks related to prior results - ReflectionTestUtils.setField(participantScoreScheduleService, "lastScheduledRun", Optional.of(Instant.now())); - ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; - participantScoreScheduleService.activate(); ZonedDateTime pastTimestamp = ZonedDateTime.now().minusDays(5); // creating the users student1, tutor1 and instructors1 userUtilService.addUsers(TEST_PREFIX, 1, 1, 0, 1); diff --git a/src/test/java/de/tum/cit/aet/artemis/assessment/ResultListenerIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/assessment/ResultListenerIntegrationTest.java index 4877e18e8c99..dffe1451edf7 100644 --- a/src/test/java/de/tum/cit/aet/artemis/assessment/ResultListenerIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/assessment/ResultListenerIntegrationTest.java @@ -4,10 +4,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; -import java.time.Instant; import java.time.ZonedDateTime; import java.util.List; -import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.AfterEach; @@ -17,7 +15,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.util.ReflectionTestUtils; import de.tum.cit.aet.artemis.assessment.domain.ParticipantScore; import de.tum.cit.aet.artemis.assessment.domain.Result; @@ -84,11 +81,7 @@ void cleanup() { @BeforeEach void setupTestScenario() { - // Prevents the ParticipantScoreScheduleService from scheduling tasks related to prior results - ReflectionTestUtils.setField(participantScoreScheduleService, "lastScheduledRun", Optional.of(Instant.now())); - ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 100; - participantScoreScheduleService.activate(); ZonedDateTime pastReleaseDate = ZonedDateTime.now().minusDays(5); ZonedDateTime pastDueDate = ZonedDateTime.now().minusDays(3); ZonedDateTime pastAssessmentDueDate = ZonedDateTime.now().minusDays(2); diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/competency/CourseCompetencyIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/competency/CourseCompetencyIntegrationTest.java index 748a1f4d5b0f..df2c561bda7e 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/competency/CourseCompetencyIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/competency/CourseCompetencyIntegrationTest.java @@ -50,8 +50,6 @@ class CourseCompetencyIntegrationTest extends AbstractCompetencyPrerequisiteInte @BeforeEach void setupTestScenario() { super.setupTestScenario(TEST_PREFIX, course -> competencyUtilService.createCompetency(course, "penguin")); - - participantScoreScheduleService.activate(); } private Result createExerciseParticipationSubmissionAndResult(Exercise exercise, StudentParticipation studentParticipation, double pointsOfExercise, diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/notification/NotificationScheduleServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/notification/NotificationScheduleServiceTest.java index 21b6a8abdaed..c49163742ba4 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/notification/NotificationScheduleServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/notification/NotificationScheduleServiceTest.java @@ -6,7 +6,6 @@ import static org.awaitility.Awaitility.await; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anySet; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; @@ -81,7 +80,6 @@ void init() { exercise.setMaxPoints(5.0); exerciseRepository.saveAndFlush(exercise); - doNothing().when(javaMailSender).send(any(MimeMessage.class)); sizeBefore = notificationRepository.count(); } diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/notification/SingleUserNotificationServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/notification/SingleUserNotificationServiceTest.java index 9a3f7db09aff..347438c89a97 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/notification/SingleUserNotificationServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/notification/SingleUserNotificationServiceTest.java @@ -22,12 +22,14 @@ import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.MESSAGE_REPLY_IN_CONVERSATION_TITLE; import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.NEW_PLAGIARISM_CASE_STUDENT_TITLE; import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.PLAGIARISM_CASE_VERDICT_STUDENT_TITLE; +import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.TUTORIAL_GROUP_ASSIGNED_TEXT; import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.TUTORIAL_GROUP_ASSIGNED_TITLE; import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.TUTORIAL_GROUP_DEREGISTRATION_STUDENT_TITLE; import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.TUTORIAL_GROUP_DEREGISTRATION_TUTOR_TITLE; import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.TUTORIAL_GROUP_REGISTRATION_MULTIPLE_TUTOR_TITLE; import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.TUTORIAL_GROUP_REGISTRATION_STUDENT_TITLE; import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.TUTORIAL_GROUP_REGISTRATION_TUTOR_TITLE; +import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.TUTORIAL_GROUP_UNASSIGNED_TEXT; import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.TUTORIAL_GROUP_UNASSIGNED_TITLE; import static de.tum.cit.aet.artemis.communication.service.notifications.NotificationSettingsService.NOTIFICATION_USER_NOTIFICATION_DATA_EXPORT_CREATED; import static de.tum.cit.aet.artemis.communication.service.notifications.NotificationSettingsService.NOTIFICATION_USER_NOTIFICATION_DATA_EXPORT_FAILED; @@ -40,7 +42,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anySet; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; @@ -63,6 +64,7 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.springframework.beans.factory.annotation.Autowired; import de.tum.cit.aet.artemis.assessment.domain.AssessmentType; @@ -132,6 +134,12 @@ class SingleUserNotificationServiceTest extends AbstractSpringIntegrationIndepen @Autowired private ParticipationUtilService participationUtilService; + @Captor + private ArgumentCaptor appleNotificationCaptor; + + @Captor + private ArgumentCaptor firebaseNotificationCaptor; + private User user; private User userTwo; @@ -263,8 +271,6 @@ void setUp() { dataExport = new DataExport(); dataExport.setUser(user); - - doNothing().when(javaMailSender).send(any(MimeMessage.class)); } /** @@ -273,8 +279,10 @@ void setUp() { * @param expectedNotificationTitle is the title (NotificationTitleTypeConstants) of the expected notification */ private void verifyRepositoryCallWithCorrectNotification(String expectedNotificationTitle) { - Notification capturedNotification = notificationRepository.findAll().getFirst(); - assertThat(capturedNotification.getTitle()).as("Title of the captured notification should be equal to the expected one").isEqualTo(expectedNotificationTitle); + List capturedNotifications = notificationRepository.findAll(); + assertThat(capturedNotifications).isNotEmpty(); + List relevantNotifications = capturedNotifications.stream().filter(e -> e.getTitle().equals(expectedNotificationTitle)).toList(); + assertThat(relevantNotifications).as("Title of the captured notification should be equal to the expected one").hasSize(1); } /// General notify Tests @@ -531,24 +539,24 @@ void testTutorialGroupNotifications_tutorDeregistration() { @Test void testTutorialGroupNotifications_groupAssigned() { notificationSettingRepository.deleteAll(); - notificationSettingRepository - .save(new NotificationSetting(tutorialGroup.getTeachingAssistant(), true, true, true, NOTIFICATION__TUTOR_NOTIFICATION__TUTORIAL_GROUP_ASSIGN_UNASSIGN)); + User teachingAssistant = tutorialGroup.getTeachingAssistant(); + notificationSettingRepository.save(new NotificationSetting(teachingAssistant, true, true, true, NOTIFICATION__TUTOR_NOTIFICATION__TUTORIAL_GROUP_ASSIGN_UNASSIGN)); singleUserNotificationService.notifyTutorAboutAssignmentToTutorialGroup(tutorialGroup, tutorialGroup.getTeachingAssistant(), userThree); verifyRepositoryCallWithCorrectNotification(TUTORIAL_GROUP_ASSIGNED_TITLE); verifyEmail(); - verifyPush(1); + verifyPush(1, TUTORIAL_GROUP_ASSIGNED_TEXT, teachingAssistant); } @Test void testTutorialGroupNotifications_groupUnassigned() { notificationSettingRepository.deleteAll(); - notificationSettingRepository - .save(new NotificationSetting(tutorialGroup.getTeachingAssistant(), true, true, true, NOTIFICATION__TUTOR_NOTIFICATION__TUTORIAL_GROUP_ASSIGN_UNASSIGN)); + User teachingAssistant = tutorialGroup.getTeachingAssistant(); + notificationSettingRepository.save(new NotificationSetting(teachingAssistant, true, true, true, NOTIFICATION__TUTOR_NOTIFICATION__TUTORIAL_GROUP_ASSIGN_UNASSIGN)); singleUserNotificationService.notifyTutorAboutUnassignmentFromTutorialGroup(tutorialGroup, tutorialGroup.getTeachingAssistant(), userThree); verifyRepositoryCallWithCorrectNotification(TUTORIAL_GROUP_UNASSIGNED_TITLE); verifyEmail(); - verifyPush(1); + verifyPush(1, TUTORIAL_GROUP_UNASSIGNED_TEXT, teachingAssistant); } @Test @@ -579,9 +587,20 @@ private void verifyEmail() { * * @param times how often the email should have been sent */ - private void verifyPush(int times) { - verify(applePushNotificationService, timeout(1500).times(times)).sendNotification(any(Notification.class), anySet(), any(Object.class)); - verify(firebasePushNotificationService, timeout(1500).times(times)).sendNotification(any(Notification.class), anySet(), any(Object.class)); + private void verifyPush(int times, String text, User recipient) { + verify(applePushNotificationService, timeout(1500).atLeast(times)).sendNotification(appleNotificationCaptor.capture(), anySet(), any(Object.class)); + verify(firebasePushNotificationService, timeout(1500).atLeast(times)).sendNotification(firebaseNotificationCaptor.capture(), anySet(), any(Object.class)); + + List appleNotifications = filterRelevantNotifications(appleNotificationCaptor.getAllValues(), text, recipient); + assertThat(appleNotifications).as(times + " Apple notifications should have been sent").hasSize(times); + + List firebaseNotifications = filterRelevantNotifications(firebaseNotificationCaptor.getAllValues(), text, recipient); + assertThat(firebaseNotifications).as(times + " Firebase notifications should have been sent").hasSize(times); + } + + private List filterRelevantNotifications(List notifications, String title, User recipient) { + return notifications.stream().filter(notification -> notification instanceof SingleUserNotification).map(notification -> (SingleUserNotification) notification) + .filter(notification -> title.equals(notification.getText()) && recipient.getId().equals(notification.getRecipient().getId())).toList(); } private static Stream getNotificationTypesAndTitlesParametersForGroupChat() { diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/notifications/service/TutorialGroupNotificationServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/notifications/service/TutorialGroupNotificationServiceTest.java index 23ad94f5a00f..26816a87c239 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/notifications/service/TutorialGroupNotificationServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/notifications/service/TutorialGroupNotificationServiceTest.java @@ -4,8 +4,6 @@ import static de.tum.cit.aet.artemis.communication.domain.notification.NotificationConstants.TUTORIAL_GROUP_UPDATED_TITLE; import static de.tum.cit.aet.artemis.communication.service.notifications.NotificationSettingsService.NOTIFICATION__TUTORIAL_GROUP_NOTIFICATION__TUTORIAL_GROUP_DELETE_UPDATE; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; @@ -15,8 +13,6 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; -import jakarta.mail.internet.MimeMessage; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -86,7 +82,6 @@ void setUp() { userRepository.findOneByLogin(TEST_PREFIX + "tutor1").orElseThrow(), IntStream.range(1, STUDENT_COUNT + 1) .mapToObj((studentId) -> userRepository.findOneByLogin(TEST_PREFIX + "student" + studentId).orElseThrow()).collect(Collectors.toSet())); - doNothing().when(javaMailSender).send(any(MimeMessage.class)); tutorialGroupNotificationRepository.deleteAll(); notificationSettingRepository.deleteAll(); } diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/service/EmailSummaryServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/service/EmailSummaryServiceTest.java index 4a42a5a826a5..a54be369017e 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/service/EmailSummaryServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/service/EmailSummaryServiceTest.java @@ -3,7 +3,6 @@ import static de.tum.cit.aet.artemis.communication.service.notifications.NotificationSettingsService.NOTIFICATION__WEEKLY_SUMMARY__BASIC_WEEKLY_SUMMARY; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.any; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; @@ -118,8 +117,6 @@ void setUp() { exerciseRepository.saveAll(allTestExercises); weeklyEmailSummaryService.setScheduleInterval(Duration.ofDays(7)); - - doNothing().when(javaMailSender).send(any(MimeMessage.class)); } /** diff --git a/src/test/java/de/tum/cit/aet/artemis/communication/service/PushNotificationDeviceConfigurationCleanupServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/communication/service/PushNotificationDeviceConfigurationCleanupServiceTest.java index dc59407ff76b..c8b142c4dc59 100644 --- a/src/test/java/de/tum/cit/aet/artemis/communication/service/PushNotificationDeviceConfigurationCleanupServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/communication/service/PushNotificationDeviceConfigurationCleanupServiceTest.java @@ -1,10 +1,9 @@ package de.tum.cit.aet.artemis.communication.service; -import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Set; @@ -54,6 +53,7 @@ void cleanupTest() { List result = deviceConfigurationRepository.findByUserIn(Set.of(user), PushNotificationDeviceType.FIREBASE); - assertEquals("The result is not correct", Collections.singletonList(valid), result); + assertThat(result).contains(valid); + assertThat(result).doesNotContain(expired); } } diff --git a/src/test/java/de/tum/cit/aet/artemis/core/MetricsIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/core/MetricsIntegrationTest.java index 2b1b73492764..4aefa856aef2 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/MetricsIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/MetricsIntegrationTest.java @@ -4,7 +4,6 @@ import static de.tum.cit.aet.artemis.core.util.TimeUtil.toRelativeTime; import static org.assertj.core.api.Assertions.assertThat; -import java.time.Instant; import java.util.Comparator; import java.util.HashSet; import java.util.List; @@ -20,7 +19,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.util.ReflectionTestUtils; import de.tum.cit.aet.artemis.assessment.domain.ParticipantScore; import de.tum.cit.aet.artemis.assessment.repository.StudentScoreRepository; @@ -84,10 +82,7 @@ class MetricsIntegrationTest extends AbstractSpringIntegrationIndependentTest { @BeforeEach void setupTestScenario() throws Exception { - // Prevents the ParticipantScoreScheduleService from scheduling tasks related to prior results - ReflectionTestUtils.setField(participantScoreScheduleService, "lastScheduledRun", Optional.of(Instant.now())); ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 100; - participantScoreScheduleService.activate(); userUtilService.addUsers(TEST_PREFIX, 3, 1, 1, 1); diff --git a/src/test/java/de/tum/cit/aet/artemis/core/StatisticsIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/core/StatisticsIntegrationTest.java index bcd5a8d5c8ae..3a483ba5b736 100644 --- a/src/test/java/de/tum/cit/aet/artemis/core/StatisticsIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/core/StatisticsIntegrationTest.java @@ -95,7 +95,6 @@ class StatisticsIntegrationTest extends AbstractSpringIntegrationIndependentTest @BeforeEach void initTestCase() { - participantScoreScheduleService.activate(); userUtilService.addUsers(TEST_PREFIX, NUMBER_OF_STUDENTS, 1, 0, 1); course = modelingExerciseUtilService.addCourseWithOneModelingExercise(); diff --git a/src/test/java/de/tum/cit/aet/artemis/exam/ExamIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/exam/ExamIntegrationTest.java index 99e6b153def6..52f93e2d6741 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exam/ExamIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exam/ExamIntegrationTest.java @@ -203,7 +203,6 @@ void setup() { userTestRepository.save(instructor10); ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; - participantScoreScheduleService.activate(); } @BeforeEach diff --git a/src/test/java/de/tum/cit/aet/artemis/exam/ExamParticipationIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/exam/ExamParticipationIntegrationTest.java index 0483663734ad..ec49d8ec78a7 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exam/ExamParticipationIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exam/ExamParticipationIntegrationTest.java @@ -185,7 +185,6 @@ void initTestCase() { gitlabRequestMockProvider.enableMockingOfRequests(); ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; - participantScoreScheduleService.activate(); } @AfterEach diff --git a/src/test/java/de/tum/cit/aet/artemis/exam/ExamRegistrationIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/exam/ExamRegistrationIntegrationTest.java index 016803accf6b..8402a7e431f3 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exam/ExamRegistrationIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exam/ExamRegistrationIntegrationTest.java @@ -88,7 +88,6 @@ void initTestCase() { examUtilService.addStudentExamForTestExam(testExam1, student1); ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; - participantScoreScheduleService.activate(); } @AfterEach diff --git a/src/test/java/de/tum/cit/aet/artemis/exam/ExamStartTest.java b/src/test/java/de/tum/cit/aet/artemis/exam/ExamStartTest.java index 399cb4306f6d..ea6ca670ad01 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exam/ExamStartTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exam/ExamStartTest.java @@ -112,7 +112,6 @@ void initTestCase() throws GitAPIException { exam = examUtilService.addExamWithExerciseGroup(course1, true); ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; - participantScoreScheduleService.activate(); doNothing().when(gitService).combineAllCommitsOfRepositoryIntoOne(any()); diff --git a/src/test/java/de/tum/cit/aet/artemis/exam/ProgrammingExamIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/exam/ProgrammingExamIntegrationTest.java index ef813745a412..a598f6ddbb79 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exam/ProgrammingExamIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exam/ProgrammingExamIntegrationTest.java @@ -100,7 +100,6 @@ void initTestCase() { gitlabRequestMockProvider.enableMockingOfRequests(); ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; - participantScoreScheduleService.activate(); } @AfterEach diff --git a/src/test/java/de/tum/cit/aet/artemis/exam/TestExamIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/exam/TestExamIntegrationTest.java index 159866350e68..0853bed96f6c 100644 --- a/src/test/java/de/tum/cit/aet/artemis/exam/TestExamIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/exam/TestExamIntegrationTest.java @@ -74,7 +74,6 @@ void initTestCase() { examUtilService.addStudentExamForTestExam(testExam1, student1); ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 200; - participantScoreScheduleService.activate(); } @Test diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/CourseGitlabJenkinsIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/CourseGitlabJenkinsIntegrationTest.java index 50e46b7e57db..f23887c80d43 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/CourseGitlabJenkinsIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/CourseGitlabJenkinsIntegrationTest.java @@ -46,7 +46,6 @@ class CourseGitlabJenkinsIntegrationTest extends AbstractSpringIntegrationJenkin @BeforeEach void setup() { - participantScoreScheduleService.activate(); courseTestService.setup(TEST_PREFIX, this); gitlabRequestMockProvider.enableMockingOfRequests(); jenkinsRequestMockProvider.enableMockingOfRequests(jenkinsServer); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseTestCaseServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseTestCaseServiceTest.java index 2d985741a637..cf293bf0740c 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseTestCaseServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/ProgrammingExerciseTestCaseServiceTest.java @@ -4,7 +4,6 @@ import static org.mockito.Mockito.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.Collections; @@ -95,7 +94,7 @@ void shouldResetExamExerciseTestCases() { private void testResetTestCases(ProgrammingExercise programmingExercise, Visibility expectedVisibility) { String dummyHash = "9b3a9bd71a0d80e5bbc42204c319ed3d1d4f0d6d"; - when(gitService.getLastCommitHash(any())).thenReturn(ObjectId.fromString(dummyHash)); + doReturn(ObjectId.fromString(dummyHash)).when(gitService).getLastCommitHash(any()); participationUtilService.addProgrammingParticipationWithResultForExercise(programmingExercise, TEST_PREFIX + "student1"); new ArrayList<>(testCaseRepository.findByExerciseId(programmingExercise.getId())).getFirst().weight(50.0); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportIntegrationTest.java index d0144595c37b..6d5bf267139a 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportIntegrationTest.java @@ -53,6 +53,11 @@ void initTestCase() throws Exception { exercise = ProgrammingExerciseFactory.generateProgrammingExercise(ZonedDateTime.now().minusDays(1), ZonedDateTime.now().plusDays(7), course); } + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + @AfterEach void cleanup() throws Exception { solutionRepo.resetLocalRepo(); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportServiceTest.java index fefa75a3cf8a..70e3c6fb8301 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/ProgrammingExerciseGitDiffReportServiceTest.java @@ -58,6 +58,11 @@ class ProgrammingExerciseGitDiffReportServiceTest extends AbstractLocalCILocalVC @Autowired private ProgrammingExerciseGitDiffReportRepository reportRepository; + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + @BeforeEach void initTestCase() { userUtilService.addUsers(TEST_PREFIX, 1, 1, 1, 1); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/StructuralTestCaseServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/StructuralTestCaseServiceTest.java index 21e976498ece..08d6f5994ac7 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/StructuralTestCaseServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/StructuralTestCaseServiceTest.java @@ -56,6 +56,11 @@ class StructuralTestCaseServiceTest extends AbstractLocalCILocalVCIntegrationTes private ProgrammingExercise exercise; + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + @BeforeEach void initTestCase() { Course course = courseUtilService.addEmptyCourse(); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/TestwiseCoverageReportServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/TestwiseCoverageReportServiceTest.java index fc7b8542022a..ba89b52dc804 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/TestwiseCoverageReportServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/TestwiseCoverageReportServiceTest.java @@ -69,6 +69,11 @@ class TestwiseCoverageReportServiceTest extends AbstractLocalCILocalVCIntegratio private final LocalRepository solutionRepo = new LocalRepository("main"); + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + @BeforeEach void setup() throws Exception { userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 1); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/behavioral/BehavioralTestCaseServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/behavioral/BehavioralTestCaseServiceTest.java index 802284ffd99f..f646dd652ded 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/hestia/behavioral/BehavioralTestCaseServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/hestia/behavioral/BehavioralTestCaseServiceTest.java @@ -78,6 +78,11 @@ class BehavioralTestCaseServiceTest extends AbstractLocalCILocalVCIntegrationTes private ProgrammingExercise exercise; + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + @BeforeEach void initTestCase() { userUtilService.addUsers(TEST_PREFIX, 0, 0, 0, 1); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/AbstractLocalCILocalVCIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/AbstractLocalCILocalVCIntegrationTest.java index 08fe3a292db2..0f559f0c3a50 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/AbstractLocalCILocalVCIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/AbstractLocalCILocalVCIntegrationTest.java @@ -30,9 +30,7 @@ import de.tum.cit.aet.artemis.programming.util.ProgrammingExerciseUtilService; import de.tum.cit.aet.artemis.shared.base.AbstractSpringIntegrationLocalCILocalVCTest; -public class AbstractLocalCILocalVCIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCTest { - - protected static final String TEST_PREFIX = "localvclocalciintegration"; +public abstract class AbstractLocalCILocalVCIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCTest { @Autowired protected TeamRepository teamRepository; @@ -112,20 +110,23 @@ public class AbstractLocalCILocalVCIntegrationTest extends AbstractSpringIntegra protected String auxiliaryRepositorySlug; + protected abstract String getTestPrefix(); + @BeforeEach void initUsersAndExercise() throws JsonProcessingException { // The port cannot be injected into the LocalVCLocalCITestService because {local.server.port} is not available when the class is instantiated. // Thus, "inject" the port from here. localVCLocalCITestService.setPort(port); - List users = userUtilService.addUsers(TEST_PREFIX, 2, 1, 0, 2); - student1Login = TEST_PREFIX + "student1"; + String testPrefix = getTestPrefix(); + List users = userUtilService.addUsers(testPrefix, 2, 1, 0, 2); + student1Login = testPrefix + "student1"; student1 = users.stream().filter(user -> student1Login.equals(user.getLogin())).findFirst().orElseThrow(); - student2Login = TEST_PREFIX + "student2"; - tutor1Login = TEST_PREFIX + "tutor1"; - instructor1Login = TEST_PREFIX + "instructor1"; + student2Login = testPrefix + "student2"; + tutor1Login = testPrefix + "tutor1"; + instructor1Login = testPrefix + "instructor1"; instructor1 = users.stream().filter(user -> instructor1Login.equals(user.getLogin())).findFirst().orElseThrow(); - instructor2Login = TEST_PREFIX + "instructor2"; + instructor2Login = testPrefix + "instructor2"; instructor2 = users.stream().filter(user -> instructor2Login.equals(user.getLogin())).findFirst().orElseThrow(); // Remove instructor2 from the instructor group of the course. instructor2.setGroups(Set.of()); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java index b48bd518766e..46fcf73bdfb3 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java @@ -81,6 +81,8 @@ @Execution(ExecutionMode.SAME_THREAD) class LocalCIIntegrationTest extends AbstractLocalCILocalVCIntegrationTest { + private static final String TEST_PREFIX = "localciint"; + @Autowired private LocalVCServletService localVCServletService; @@ -99,6 +101,11 @@ class LocalCIIntegrationTest extends AbstractLocalCILocalVCIntegrationTest { @Value("${artemis.user-management.internal-admin.password}") private String localVCPassword; + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + private LocalRepository studentAssignmentRepository; private LocalRepository testsRepository; @@ -262,7 +269,8 @@ void testCommitHashNull() { // Should still work because in that case the latest commit should be retrieved from the repository. localVCServletService.processNewPush(null, studentAssignmentRepository.originGit.getRepository()); - localVCLocalCITestService.testLatestSubmission(studentParticipation.getId(), commitHash, 1, false); + // ToDo: Investigate why specifically this test requires so much time (all other << 5s) + localVCLocalCITestService.testLatestSubmission(studentParticipation.getId(), commitHash, 1, false, 120); } @Test @@ -291,7 +299,7 @@ void testProjectTypeIsNull() { @Test @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") - void testCannotFindResults() { + void testResultsNotFound() { ProgrammingExerciseStudentParticipation studentParticipation = localVCLocalCITestService.createParticipation(programmingExercise, student1Login); // Should return a build result that indicates that the build failed. diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java index b57491c34db5..83f9d9019f27 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java @@ -42,6 +42,8 @@ class LocalCIResourceIntegrationTest extends AbstractLocalCILocalVCIntegrationTest { + private static final String TEST_PREFIX = "localciresourceint"; + @Autowired @Qualifier("hazelcastInstance") private HazelcastInstance hazelcastInstance; @@ -75,6 +77,11 @@ class LocalCIResourceIntegrationTest extends AbstractLocalCILocalVCIntegrationTe protected IMap buildAgentInformation; + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + @BeforeEach void createJobs() { // temporarily remove listener to avoid triggering build job processing @@ -136,11 +143,10 @@ void clearDataStructures() { @WithMockUser(username = TEST_PREFIX + "admin", roles = "ADMIN") void testGetQueuedBuildJobs_returnsJobs() throws Exception { var retrievedJobs = request.get("/api/admin/queued-jobs", HttpStatus.OK, List.class); - assertThat(retrievedJobs).isEmpty(); // Adding a lot of jobs as they get processed very quickly due to mocking queuedJobs.addAll(List.of(job1, job2)); var retrievedJobs1 = request.get("/api/admin/queued-jobs", HttpStatus.OK, List.class); - assertThat(retrievedJobs1).hasSize(2); + assertThat(retrievedJobs1).hasSize(retrievedJobs.size() + 2); } @Test diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResultServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResultServiceTest.java index e0947713cf57..a951d065e0cc 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResultServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResultServiceTest.java @@ -14,9 +14,16 @@ class LocalCIResultServiceTest extends AbstractLocalCILocalVCIntegrationTest { + private static final String TEST_PREFIX = "localciresultservice"; + @Autowired private LocalCIResultService localCIResultService; + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + @Test void testThrowsExceptionWhenResultIsNotLocalCIBuildResult() { var wrongBuildResult = ProgrammingExerciseFactory.generateTestResultDTO("some-name", "some-repository", ZonedDateTime.now().minusSeconds(10), diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCIntegrationTest.java index e57b850240a2..1531a4e2649a 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCIntegrationTest.java @@ -37,6 +37,8 @@ */ class LocalVCIntegrationTest extends AbstractLocalCILocalVCIntegrationTest { + private static final String TEST_PREFIX = "localvcint"; + private LocalRepository assignmentRepository; private LocalRepository templateRepository; @@ -60,6 +62,11 @@ void initRepositories() throws GitAPIException, IOException, URISyntaxException testsRepository = localVCLocalCITestService.createAndConfigureLocalRepository(projectKey1, projectKey1.toLowerCase() + "-tests"); } + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + @AfterEach void removeRepositories() throws IOException { assignmentRepository.resetLocalRepo(); @@ -77,7 +84,16 @@ void testFetchPush_repositoryDoesNotExist() throws IOException, GitAPIException, // Delete the remote repository. someRepository.originGit.close(); - FileUtils.deleteDirectory(someRepository.originRepoFile); + try { + FileUtils.deleteDirectory(someRepository.originRepoFile); + } + catch (IOException exception) { + // JGit creates a lock file in each repository that could cause deletion problems. + if (exception.getMessage().contains("gc.log.lock")) { + return; + } + throw exception; + } // Try to fetch from the remote repository. localVCLocalCITestService.testFetchThrowsException(someRepository.localGit, student1Login, USER_PASSWORD, projectKey, repositorySlug, InvalidRemoteException.class, ""); @@ -122,7 +138,7 @@ void testFetchPush_usingVcsAccessToken() { @Test void testFetchPush_wrongCredentials() throws InvalidNameException { - var student1 = new LdapUserDto().login(TEST_PREFIX + "student1"); + var student1 = new LdapUserDto().login(getTestPrefix() + "student1"); student1.setUid(new LdapName("cn=student1,ou=test,o=lab")); var fakeUser = new LdapUserDto().login(localVCBaseUsername); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCIIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCIIntegrationTest.java index 526d5b8a522f..877ccd6493e0 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCIIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCIIntegrationTest.java @@ -87,6 +87,8 @@ class LocalVCLocalCIIntegrationTest extends AbstractLocalCILocalVCIntegrationTes private static final Logger log = LoggerFactory.getLogger(LocalVCLocalCIIntegrationTest.class); + private static final String TEST_PREFIX = "localvcciint"; + @Autowired private ExamUtilService examUtilService; @@ -124,6 +126,11 @@ class LocalVCLocalCIIntegrationTest extends AbstractLocalCILocalVCIntegrationTes protected IQueue queuedJobs; + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + @BeforeAll void setupAll() { CredentialsProvider.setDefault(new UsernamePasswordCredentialsProvider(localVCUsername, localVCPassword)); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCITestService.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCITestService.java index 08c02eedceed..c6d7556e3766 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCITestService.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCITestService.java @@ -54,6 +54,7 @@ import de.tum.cit.aet.artemis.assessment.domain.Result; import de.tum.cit.aet.artemis.assessment.domain.Visibility; +import de.tum.cit.aet.artemis.assessment.service.ParticipantScoreScheduleService; import de.tum.cit.aet.artemis.assessment.test_repository.ResultTestRepository; import de.tum.cit.aet.artemis.core.domain.User; import de.tum.cit.aet.artemis.exercise.participation.util.ParticipationUtilService; @@ -91,6 +92,9 @@ public class LocalVCLocalCITestService { @Autowired private ParticipationVcsAccessTokenService participationVcsAccessTokenService; + @Autowired + private ParticipantScoreScheduleService participantScoreScheduleService; + @Autowired private ResultTestRepository resultRepository; @@ -573,7 +577,11 @@ public void testLatestSubmission(Long participationId, String expectedCommitHash int expectedCodeIssueCount, Integer timeoutInSeconds) { // wait for result to be persisted Duration timeoutDuration = timeoutInSeconds != null ? Duration.ofSeconds(timeoutInSeconds) : Duration.ofSeconds(DEFAULT_AWAITILITY_TIMEOUT_IN_SECONDS); - await().atMost(timeoutDuration).until(() -> resultRepository.findFirstWithSubmissionsByParticipationIdOrderByCompletionDateDesc(participationId).isPresent()); + await().atMost(timeoutDuration).until(() -> { + participantScoreScheduleService.executeScheduledTasks(); + await().until(participantScoreScheduleService::isIdle); + return resultRepository.findFirstWithSubmissionsByParticipationIdOrderByCompletionDateDesc(participationId).isPresent(); + }); Authentication auth = SecurityContextHolder.getContext().getAuthentication(); List submissions = programmingSubmissionRepository.findAllByParticipationIdWithResults(participationId); @@ -609,6 +617,10 @@ public void testLatestSubmission(Long participationId, String expectedCommitHash testLatestSubmission(participationId, expectedCommitHash, expectedSuccessfulTestCaseCount, buildFailed, false, 0, null); } + public void testLatestSubmission(Long participationId, String expectedCommitHash, int expectedSuccessfulTestCaseCount, boolean buildFailed, int timeoutInSeconds) { + testLatestSubmission(participationId, expectedCommitHash, expectedSuccessfulTestCaseCount, buildFailed, false, 0, timeoutInSeconds); + } + /** * Perform a push operation and fail if there was no exception. * diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCSshIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCSshIntegrationTest.java index ac20db1c209c..bca0ed60beb9 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCSshIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCSshIntegrationTest.java @@ -12,6 +12,7 @@ import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; +import java.util.Objects; import java.util.concurrent.TimeUnit; import org.apache.sshd.client.SshClient; @@ -20,6 +21,7 @@ import org.apache.sshd.common.SshException; import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter; +import org.apache.sshd.common.session.helpers.AbstractSession; import org.apache.sshd.server.SshServer; import org.apache.sshd.server.session.ServerSession; import org.junit.jupiter.api.Test; @@ -35,9 +37,16 @@ @Profile(PROFILE_LOCALVC) class LocalVCSshIntegrationTest extends LocalVCIntegrationTest { + private static final String TEST_PREFIX = "localvcsshint"; + @Autowired private SshServer sshServer; + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + private final String hostname = "localhost"; private final int port = 7921; @@ -111,8 +120,8 @@ void testAuthenticationFailure() { void testConnectOverSshAndReceivePack() throws IOException, GeneralSecurityException { try (var client = clientConnectToArtemisSshServer()) { assertThat(client).isNotNull(); - var serverSessions = sshServer.getActiveSessions(); - var serverSession = serverSessions.getFirst(); + var user = userTestRepository.getUser(); + var serverSession = getCurrentServerSession(user); final var uploadCommandString = "git-upload-pack '/git/" + projectKey1 + "/" + templateRepositorySlug + "'"; @@ -144,9 +153,13 @@ private SshGitCommand setupCommand(String commandString, ServerSession serverSes return command; } + /** + * Note: Don't count unattached sessions as a potential result from previous tests. + * See {@link org.apache.sshd.server.SshServer#getActiveSessions} + * and {@link org.apache.sshd.common.session.helpers.AbstractSession#getSession}. + */ private SshClient clientConnectToArtemisSshServer() throws GeneralSecurityException, IOException { var serverSessions = sshServer.getActiveSessions(); - var numberOfSessions = serverSessions.size(); localVCLocalCITestService.createParticipation(programmingExercise, student1Login); KeyPair keyPair = setupKeyPairAndAddToUser(); User user = userTestRepository.getUser(); @@ -155,6 +168,7 @@ private SshClient clientConnectToArtemisSshServer() throws GeneralSecurityExcept client.start(); ClientSession clientSession; + int numberOfSessions = serverSessions.size(); try { ConnectFuture connectFuture = client.connect(user.getName(), hostname, port); connectFuture.await(10, TimeUnit.SECONDS); @@ -169,11 +183,20 @@ private SshClient clientConnectToArtemisSshServer() throws GeneralSecurityExcept } serverSessions = sshServer.getActiveSessions(); + var attachedServerSessions = serverSessions.stream().filter(Objects::nonNull).count(); assertThat(clientSession.isAuthenticated()).isTrue(); - assertThat(serverSessions.size()).isEqualTo(numberOfSessions + 1); + assertThat(attachedServerSessions).as("There are more server sessions activated than expected.").isEqualTo(numberOfSessions + 1); return client; } + private AbstractSession getCurrentServerSession(User user) { + var serverSessions = sshServer.getActiveSessions(); + // parallel tests might create additional sessions, we need to be specific + var serverSession = serverSessions.stream().filter(session -> user.getName().equals(session.getUsername())).findFirst(); + + return serverSession.orElseThrow(() -> new IllegalStateException("No server session found for user " + user.getName())); + } + private KeyPair setupKeyPairAndAddToUser() throws GeneralSecurityException, IOException { User user = userTestRepository.getUser(); diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/MultipleHostKeyProviderTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/MultipleHostKeyProviderTest.java index bf9c4cde79fc..a386ae6ccc2d 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/MultipleHostKeyProviderTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/MultipleHostKeyProviderTest.java @@ -13,6 +13,13 @@ @Profile(PROFILE_LOCALVC) class MultipleHostKeyProviderTest extends AbstractLocalCILocalVCIntegrationTest { + private static final String TEST_PREFIX = "multiplehostkeyprovider"; + + @Override + protected String getTestPrefix() { + return TEST_PREFIX; + } + @Test void testMultipleHostKeyProvider() { MultipleHostKeyProvider multipleHostKeyProvider = new MultipleHostKeyProvider(Path.of("./")); diff --git a/src/test/java/de/tum/cit/aet/artemis/quiz/QuizSubmissionIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/quiz/QuizSubmissionIntegrationTest.java index 5a56c62d5273..41736fa7c6a9 100644 --- a/src/test/java/de/tum/cit/aet/artemis/quiz/QuizSubmissionIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/quiz/QuizSubmissionIntegrationTest.java @@ -1,12 +1,12 @@ package de.tum.cit.aet.artemis.quiz; +import static de.tum.cit.aet.artemis.core.config.Constants.EXERCISE_TOPIC_ROOT; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.mockito.Mockito.any; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import java.io.IOException; import java.time.Duration; @@ -414,7 +414,7 @@ void testQuizSubmitPractice_badRequest() throws Exception { Result result = request.postWithResponseBody("/api/exercises/" + quizExerciseServer.getId() + "/submissions/practice", quizSubmission, Result.class, HttpStatus.BAD_REQUEST); assertThat(result).isNull(); - verifyNoInteractions(websocketMessagingService); + verifyNoWebsocketMessageForExercise(quizExerciseServer); } @Test @@ -431,7 +431,7 @@ void testQuizSubmitPractice_badRequest_exam() throws Exception { Result result = request.postWithResponseBody("/api/exercises/" + quizExerciseServer.getId() + "/submissions/practice", quizSubmission, Result.class, HttpStatus.BAD_REQUEST); assertThat(result).isNull(); - verifyNoInteractions(websocketMessagingService); + verifyNoWebsocketMessageForExercise(quizExerciseServer); } @Test @@ -451,7 +451,7 @@ void testQuizSubmitPractice_forbidden() throws Exception { QuizExercise quizExercise = QuizExerciseFactory.createQuiz(course, ZonedDateTime.now().minusSeconds(4), null, QuizMode.SYNCHRONIZED); quizExerciseService.save(quizExercise); request.postWithResponseBody("/api/exercises/" + quizExercise.getId() + "/submissions/practice", new QuizSubmission(), Result.class, HttpStatus.FORBIDDEN); - verifyNoInteractions(websocketMessagingService); + verifyNoWebsocketMessageForExercise(quizExercise); } @Test @@ -757,6 +757,12 @@ private QuizExercise setupQuizExerciseParameters() { return quizExercise; } + private void verifyNoWebsocketMessageForExercise(QuizExercise exercise) { + String topic = EXERCISE_TOPIC_ROOT + exercise.getId() + "/newResults"; + verify(websocketMessagingService, never()).sendMessage(eq(topic), any()); + verify(websocketMessagingService, never()).sendMessageToUser(any(), eq(topic), any()); + } + @Nested @Isolated class QuizSubmitLiveModeIsolatedTest { diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractArtemisIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractArtemisIntegrationTest.java index 4157341a3deb..469e4f117837 100644 --- a/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractArtemisIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/shared/base/AbstractArtemisIntegrationTest.java @@ -4,7 +4,9 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; +import java.time.Instant; import java.util.List; +import java.util.Optional; import jakarta.mail.internet.MimeMessage; @@ -23,6 +25,7 @@ import org.springframework.context.annotation.Import; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; import de.tum.cit.aet.artemis.assessment.service.ParticipantScoreScheduleService; import de.tum.cit.aet.artemis.assessment.test_repository.ResultTestRepository; @@ -199,6 +202,14 @@ void mockMailService() { doNothing().when(javaMailSender).send(any(MimeMessage.class)); } + @BeforeEach + void resetParticipantScoreScheduler() { + // Prevents the ParticipantScoreScheduleService from scheduling tasks related to prior results + ReflectionTestUtils.setField(participantScoreScheduleService, "lastScheduledRun", Optional.of(Instant.now())); + ParticipantScoreScheduleService.DEFAULT_WAITING_TIME_FOR_SCHEDULED_TASKS = 100; + participantScoreScheduleService.activate(); + } + @AfterEach void stopQuizScheduler() { scheduleService.clearAllTasks(); From f35832913d3a866c3c448593daba8ef022754615 Mon Sep 17 00:00:00 2001 From: Marcel Gaupp Date: Sat, 12 Oct 2024 10:43:10 +0200 Subject: [PATCH 6/6] Programming exercises: Add R programming language template (#9256) --- build.gradle | 9 +-- .../programming-exercise-features.inc | 4 ++ ...ProgrammingPlagiarismDetectionService.java | 12 ++-- .../domain/ProgrammingLanguage.java | 15 ++--- .../service/TemplateUpgradePolicyService.java | 4 +- .../ci/ContinuousIntegrationService.java | 8 +-- ...abCIProgrammingLanguageFeatureService.java | 2 +- ...kinsProgrammingLanguageFeatureService.java | 10 ++-- .../build_plan/JenkinsBuildPlanService.java | 4 +- ...alCIProgrammingLanguageFeatureService.java | 16 ++--- src/main/resources/config/application.yml | 2 + .../resources/templates/aeolus/r/default.sh | 26 ++++++++ .../resources/templates/aeolus/r/default.yaml | 14 +++++ .../jenkins/r/regularRuns/pipeline.groovy | 59 +++++++++++++++++++ .../templates/r/exercise/DESCRIPTION | 7 +++ .../resources/templates/r/exercise/NAMESPACE | 1 + .../templates/r/exercise/R/convert.R | 3 + src/main/resources/templates/r/readme | 6 ++ .../templates/r/solution/DESCRIPTION | 7 +++ .../resources/templates/r/solution/NAMESPACE | 1 + .../templates/r/solution/R/convert.R | 17 ++++++ .../resources/templates/r/test/DESCRIPTION | 14 +++++ .../templates/r/test/tests/testthat.R | 12 ++++ .../r/test/tests/testthat/test-convert.R | 47 +++++++++++++++ .../programming/programming-exercise.model.ts | 15 ++--- .../file-browser/supported-file-extensions.ts | 1 + src/test/resources/config/application.yml | 2 + 27 files changed, 275 insertions(+), 43 deletions(-) create mode 100644 src/main/resources/templates/aeolus/r/default.sh create mode 100644 src/main/resources/templates/aeolus/r/default.yaml create mode 100644 src/main/resources/templates/jenkins/r/regularRuns/pipeline.groovy create mode 100644 src/main/resources/templates/r/exercise/DESCRIPTION create mode 100644 src/main/resources/templates/r/exercise/NAMESPACE create mode 100644 src/main/resources/templates/r/exercise/R/convert.R create mode 100644 src/main/resources/templates/r/readme create mode 100644 src/main/resources/templates/r/solution/DESCRIPTION create mode 100644 src/main/resources/templates/r/solution/NAMESPACE create mode 100644 src/main/resources/templates/r/solution/R/convert.R create mode 100644 src/main/resources/templates/r/test/DESCRIPTION create mode 100644 src/main/resources/templates/r/test/tests/testthat.R create mode 100644 src/main/resources/templates/r/test/tests/testthat/test-convert.R diff --git a/build.gradle b/build.gradle index 29da90bf3674..cdad264bac58 100644 --- a/build.gradle +++ b/build.gradle @@ -246,14 +246,15 @@ dependencies { implementation "org.gitlab4j:gitlab4j-api:6.0.0-rc.5" implementation "de.jplag:jplag:${jplag_version}" - implementation "de.jplag:java:${jplag_version}" - implementation "de.jplag:kotlin:${jplag_version}" + implementation "de.jplag:c:${jplag_version}" - implementation "de.jplag:swift:${jplag_version}" implementation "de.jplag:java:${jplag_version}" + implementation "de.jplag:javascript:${jplag_version}" + implementation "de.jplag:kotlin:${jplag_version}" implementation "de.jplag:python-3:${jplag_version}" + implementation "de.jplag:rlang:${jplag_version}" implementation "de.jplag:rust:${jplag_version}" - implementation "de.jplag:javascript:${jplag_version}" + implementation "de.jplag:swift:${jplag_version}" implementation "de.jplag:text:${jplag_version}" // those are transitive dependencies of JPlag Text --> Stanford NLP diff --git a/docs/user/exercises/programming-exercise-features.inc b/docs/user/exercises/programming-exercise-features.inc index 7bccf1596315..660e2bd4bf02 100644 --- a/docs/user/exercises/programming-exercise-features.inc +++ b/docs/user/exercises/programming-exercise-features.inc @@ -37,6 +37,8 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------+---------+ | JavaScript | yes | yes | +----------------------+----------+---------+ + | R | yes | yes | + +----------------------+----------+---------+ - Not all ``templates`` support the same feature set and supported features can also change depending on the continuous integration system setup. Depending on the feature set, some options might not be available during the creation of the programming exercise. @@ -71,6 +73,8 @@ Instructors can still use those templates to generate programming exercises and +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ | JavaScript | no | no | yes | no | n/a | no | no | L: yes, J: no | +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ + | R | no | no | yes | no | n/a | no | no | L: yes, J: no | + +----------------------+----------------------+----------------------+---------------------+--------------+------------------------------------------+------------------------------+----------------------------+------------------------+ - *Sequential Test Runs*: ``Artemis`` can generate a build plan which first executes structural and then behavioral tests. This feature can help students to better concentrate on the immediate challenge at hand. - *Static Code Analysis*: ``Artemis`` can generate a build plan which additionally executes static code analysis tools. diff --git a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/ProgrammingPlagiarismDetectionService.java b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/ProgrammingPlagiarismDetectionService.java index 9fe9b1cc0f8c..ea6ebfdf3b81 100644 --- a/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/ProgrammingPlagiarismDetectionService.java +++ b/src/main/java/de/tum/cit/aet/artemis/plagiarism/service/ProgrammingPlagiarismDetectionService.java @@ -37,6 +37,7 @@ import de.jplag.options.JPlagOptions; import de.jplag.python3.PythonLanguage; import de.jplag.reporting.reportobject.ReportObjectFactory; +import de.jplag.rlang.RLanguage; import de.jplag.rust.RustLanguage; import de.jplag.swift.SwiftLanguage; import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; @@ -310,14 +311,15 @@ public void deleteTempLocalRepository(Repository repository) { private Language getJPlagProgrammingLanguage(ProgrammingExercise programmingExercise) { return switch (programmingExercise.getProgrammingLanguage()) { - case JAVA -> new JavaLanguage(); case C -> new CLanguage(); - case PYTHON -> new PythonLanguage(); - case SWIFT -> new SwiftLanguage(); + case JAVA -> new JavaLanguage(); + case JAVASCRIPT -> new JavaScriptLanguage(); case KOTLIN -> new KotlinLanguage(); + case PYTHON -> new PythonLanguage(); + case R -> new RLanguage(); case RUST -> new RustLanguage(); - case JAVASCRIPT -> new JavaScriptLanguage(); - case EMPTY, PHP, DART, HASKELL, ASSEMBLER, OCAML, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, VHDL, RUBY, POWERSHELL, ADA -> + case SWIFT -> new SwiftLanguage(); + case EMPTY, PHP, DART, HASKELL, ASSEMBLER, OCAML, C_SHARP, C_PLUS_PLUS, SQL, TYPESCRIPT, GO, MATLAB, BASH, VHDL, RUBY, POWERSHELL, ADA -> throw new BadRequestAlertException("Programming language " + programmingExercise.getProgrammingLanguage() + " not supported for plagiarism check.", "ProgrammingExercise", "notSupported"); }; diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java index 4206bfe15dbc..781ad04f98c6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/ProgrammingLanguage.java @@ -38,18 +38,19 @@ public enum ProgrammingLanguage { PHP("php"); private static final Set ENABLED_LANGUAGES = Set.of( - EMPTY, - JAVA, - PYTHON, + ASSEMBLER, C, HASKELL, + JAVA, + JAVASCRIPT, KOTLIN, - VHDL, - ASSEMBLER, - SWIFT, OCAML, + PYTHON, + R, RUST, - JAVASCRIPT + SWIFT, + VHDL, + EMPTY ); // @formatter:on diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java index 16285e6a0695..4c73046b1ab0 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/TemplateUpgradePolicyService.java @@ -32,8 +32,8 @@ public TemplateUpgradePolicyService(JavaTemplateUpgradeService javaRepositoryUpg public TemplateUpgradeService getUpgradeService(ProgrammingLanguage programmingLanguage) { return switch (programmingLanguage) { case JAVA -> javaRepositoryUpgradeService; - case KOTLIN, PYTHON, C, HASKELL, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT -> defaultRepositoryUpgradeService; - case C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case KOTLIN, PYTHON, C, HASKELL, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT, R -> defaultRepositoryUpgradeService; + case C_SHARP, C_PLUS_PLUS, SQL, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + programmingLanguage); }; } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java index b4f67794c073..b9050501c67a 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ci/ContinuousIntegrationService.java @@ -219,8 +219,8 @@ enum RepositoryCheckoutPath implements CustomizableCheckoutPath { @Override public String forProgrammingLanguage(ProgrammingLanguage language) { return switch (language) { - case JAVA, PYTHON, C, HASKELL, KOTLIN, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT -> "assignment"; - case C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case JAVA, PYTHON, C, HASKELL, KOTLIN, VHDL, ASSEMBLER, SWIFT, OCAML, EMPTY, RUST, JAVASCRIPT, R -> "assignment"; + case C_SHARP, C_PLUS_PLUS, SQL, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); }; } @@ -230,9 +230,9 @@ public String forProgrammingLanguage(ProgrammingLanguage language) { @Override public String forProgrammingLanguage(ProgrammingLanguage language) { return switch (language) { - case JAVA, PYTHON, HASKELL, KOTLIN, SWIFT, EMPTY, RUST, JAVASCRIPT -> ""; + case JAVA, PYTHON, HASKELL, KOTLIN, SWIFT, EMPTY, RUST, JAVASCRIPT, R -> ""; case C, VHDL, ASSEMBLER, OCAML -> "tests"; - case C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case C_SHARP, C_PLUS_PLUS, SQL, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException("Unsupported programming language: " + language); }; } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java index a92bcdd26cb5..0c71114e13bb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/gitlabci/GitLabCIProgrammingLanguageFeatureService.java @@ -25,7 +25,7 @@ public class GitLabCIProgrammingLanguageFeatureService extends ProgrammingLangua public GitLabCIProgrammingLanguageFeatureService() { programmingLanguageFeatures.put(EMPTY, new ProgrammingLanguageFeature(EMPTY, false, false, false, false, false, List.of(), false, false)); programmingLanguageFeatures.put(JAVA, new ProgrammingLanguageFeature(JAVA, false, false, false, true, false, List.of(PLAIN_MAVEN, MAVEN_MAVEN), false, false)); - programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false, false)); programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false, false)); + programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false, false)); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java index 38893ea41093..45a473da9148 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/JenkinsProgrammingLanguageFeatureService.java @@ -7,6 +7,7 @@ import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.JAVASCRIPT; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.KOTLIN; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.PYTHON; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.R; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.RUST; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.SWIFT; import static de.tum.cit.aet.artemis.programming.domain.ProjectType.FACT; @@ -33,15 +34,16 @@ public class JenkinsProgrammingLanguageFeatureService extends ProgrammingLanguag public JenkinsProgrammingLanguageFeatureService() { // Must be extended once a new programming language is added programmingLanguageFeatures.put(EMPTY, new ProgrammingLanguageFeature(EMPTY, false, false, false, false, false, List.of(), false, false)); + programmingLanguageFeatures.put(C, new ProgrammingLanguageFeature(C, false, false, true, false, false, List.of(FACT, GCC), false, false)); + programmingLanguageFeatures.put(HASKELL, new ProgrammingLanguageFeature(HASKELL, false, false, false, false, true, List.of(), false, false)); programmingLanguageFeatures.put(JAVA, new ProgrammingLanguageFeature(JAVA, true, true, true, true, false, List.of(PLAIN_GRADLE, GRADLE_GRADLE, PLAIN_MAVEN, MAVEN_MAVEN, MAVEN_BLACKBOX), true, false)); + programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false, false)); programmingLanguageFeatures.put(KOTLIN, new ProgrammingLanguageFeature(KOTLIN, true, false, true, true, false, List.of(), true, false)); programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, false, true, false, false, List.of(), false, false)); + programmingLanguageFeatures.put(R, new ProgrammingLanguageFeature(R, false, false, true, false, false, List.of(), false, false)); + programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false, false)); // Jenkins is not supporting XCODE at the moment programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, true, true, true, false, List.of(PLAIN), false, false)); - programmingLanguageFeatures.put(C, new ProgrammingLanguageFeature(C, false, false, true, false, false, List.of(FACT, GCC), false, false)); - programmingLanguageFeatures.put(HASKELL, new ProgrammingLanguageFeature(HASKELL, false, false, false, false, true, List.of(), false, false)); - programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false, false)); - programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false, false)); } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java index 6e904910ca57..f900cc0f6dd1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/jenkins/build_plan/JenkinsBuildPlanService.java @@ -184,8 +184,8 @@ private JenkinsXmlConfigBuilder builderFor(ProgrammingLanguage programmingLangua throw new UnsupportedOperationException("Xcode templates are not available for Jenkins."); } return switch (programmingLanguage) { - case JAVA, KOTLIN, PYTHON, C, HASKELL, SWIFT, EMPTY, RUST, JAVASCRIPT -> jenkinsBuildPlanCreator; - case VHDL, ASSEMBLER, OCAML, C_SHARP, C_PLUS_PLUS, SQL, R, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> + case JAVA, KOTLIN, PYTHON, C, HASKELL, SWIFT, EMPTY, RUST, JAVASCRIPT, R -> jenkinsBuildPlanCreator; + case VHDL, ASSEMBLER, OCAML, C_SHARP, C_PLUS_PLUS, SQL, TYPESCRIPT, GO, MATLAB, BASH, RUBY, POWERSHELL, ADA, DART, PHP -> throw new UnsupportedOperationException(programmingLanguage + " templates are not available for Jenkins."); }; } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java index 525170cca334..bc8292d407bb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/LocalCIProgrammingLanguageFeatureService.java @@ -10,6 +10,7 @@ import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.KOTLIN; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.OCAML; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.PYTHON; +import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.R; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.RUST; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.SWIFT; import static de.tum.cit.aet.artemis.programming.domain.ProgrammingLanguage.VHDL; @@ -39,17 +40,18 @@ public class LocalCIProgrammingLanguageFeatureService extends ProgrammingLanguag public LocalCIProgrammingLanguageFeatureService() { // Must be extended once a new programming language is added programmingLanguageFeatures.put(EMPTY, new ProgrammingLanguageFeature(EMPTY, false, false, false, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(ASSEMBLER, new ProgrammingLanguageFeature(ASSEMBLER, false, false, false, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(C, new ProgrammingLanguageFeature(C, false, true, true, false, false, List.of(FACT, GCC), false, true)); + programmingLanguageFeatures.put(HASKELL, new ProgrammingLanguageFeature(HASKELL, true, false, false, false, true, List.of(), false, true)); programmingLanguageFeatures.put(JAVA, new ProgrammingLanguageFeature(JAVA, true, true, true, true, false, List.of(PLAIN_GRADLE, GRADLE_GRADLE, PLAIN_MAVEN, MAVEN_MAVEN), false, true)); - programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, false, true, false, false, List.of(), false, true)); - programmingLanguageFeatures.put(C, new ProgrammingLanguageFeature(C, false, true, true, false, false, List.of(FACT, GCC), false, true)); - programmingLanguageFeatures.put(ASSEMBLER, new ProgrammingLanguageFeature(ASSEMBLER, false, false, false, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false, true)); programmingLanguageFeatures.put(KOTLIN, new ProgrammingLanguageFeature(KOTLIN, false, false, true, true, false, List.of(), false, true)); - programmingLanguageFeatures.put(VHDL, new ProgrammingLanguageFeature(VHDL, false, false, false, false, false, List.of(), false, true)); - programmingLanguageFeatures.put(HASKELL, new ProgrammingLanguageFeature(HASKELL, true, false, false, false, true, List.of(), false, true)); programmingLanguageFeatures.put(OCAML, new ProgrammingLanguageFeature(OCAML, false, false, false, false, true, List.of(), false, true)); - programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, false, true, true, false, List.of(PLAIN), false, true)); + programmingLanguageFeatures.put(PYTHON, new ProgrammingLanguageFeature(PYTHON, false, false, true, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(R, new ProgrammingLanguageFeature(R, false, false, true, false, false, List.of(), false, true)); programmingLanguageFeatures.put(RUST, new ProgrammingLanguageFeature(RUST, false, false, true, false, false, List.of(), false, true)); - programmingLanguageFeatures.put(JAVASCRIPT, new ProgrammingLanguageFeature(JAVASCRIPT, false, false, true, false, false, List.of(), false, true)); + programmingLanguageFeatures.put(SWIFT, new ProgrammingLanguageFeature(SWIFT, false, false, true, true, false, List.of(PLAIN), false, true)); + programmingLanguageFeatures.put(VHDL, new ProgrammingLanguageFeature(VHDL, false, false, false, false, false, List.of(), false, true)); } } diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index 924d087ec8f2..3924e2d804f9 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -91,6 +91,8 @@ artemis: default: "ghcr.io/ls1intum/artemis-rust-docker:v0.9.70" javascript: default: "ghcr.io/ls1intum/artemis-javascript-docker:v1.0.0" + r: + default: "ghcr.io/ls1intum/artemis-r-docker:v1.0.0" management: endpoints: diff --git a/src/main/resources/templates/aeolus/r/default.sh b/src/main/resources/templates/aeolus/r/default.sh new file mode 100644 index 000000000000..1d0b32e87105 --- /dev/null +++ b/src/main/resources/templates/aeolus/r/default.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -e +export AEOLUS_INITIAL_DIRECTORY=${PWD} +install () { + echo '⚙️ executing install' + R CMD INSTALL assignment +} + +run_all_tests () { + echo '⚙️ executing run_all_tests' + Rscript -e 'library("testthat"); options(testthat.output_file = "junit.xml"); test_local(".", reporter = "junit")' +} + +main () { + if [[ "${1}" == "aeolus_sourcing" ]]; then + return 0 # just source to use the methods in the subshell, no execution + fi + local _script_name + _script_name=${BASH_SOURCE[0]:-$0} + cd "${AEOLUS_INITIAL_DIRECTORY}" + bash -c "source ${_script_name} aeolus_sourcing; install" + cd "${AEOLUS_INITIAL_DIRECTORY}" + bash -c "source ${_script_name} aeolus_sourcing; run_all_tests" +} + +main "${@}" diff --git a/src/main/resources/templates/aeolus/r/default.yaml b/src/main/resources/templates/aeolus/r/default.yaml new file mode 100644 index 000000000000..a41d23c6f012 --- /dev/null +++ b/src/main/resources/templates/aeolus/r/default.yaml @@ -0,0 +1,14 @@ +api: v0.0.1 +metadata: + name: R + id: r + description: Test package using testthat +actions: + - name: install + script: R CMD INSTALL assignment + - name: run_all_tests + script: Rscript -e 'library("testthat"); options(testthat.output_file = "junit.xml"); test_local(".", reporter = "junit")' + results: + - name: junit + path: tests/testthat/junit.xml + type: junit diff --git a/src/main/resources/templates/jenkins/r/regularRuns/pipeline.groovy b/src/main/resources/templates/jenkins/r/regularRuns/pipeline.groovy new file mode 100644 index 000000000000..9a2ec97b5843 --- /dev/null +++ b/src/main/resources/templates/jenkins/r/regularRuns/pipeline.groovy @@ -0,0 +1,59 @@ +/* + * This file configures the actual build steps for the automatic grading. + * + * !!! + * For regular exercises, there is no need to make changes to this file. + * Only this base configuration is actively supported by the Artemis maintainers + * and/or your Artemis instance administrators. + * !!! + */ + +dockerImage = '#dockerImage' +dockerFlags = '#dockerArgs' + +/** + * Main function called by Jenkins. + */ +void testRunner() { + docker.image(dockerImage).inside(dockerFlags) { c -> + runTestSteps() + } +} + +private void runTestSteps() { + test() +} + +/** + * Run unit tests + */ +private void test() { + stage('Test') { + sh ''' + R CMD INSTALL assignment + Rscript -e 'library("testthat"); options(testthat.output_file = "junit.xml"); test_local(".", reporter = "junit")' + ''' + } +} + +/** + * Script of the post build tasks aggregating all JUnit files in $WORKSPACE/results. + * + * Called by Jenkins. + */ +void postBuildTasks() { + sh ''' + rm -rf results + mkdir results + if [ -e tests/testthat/junit.xml ] + then + sed -i 's/]*>//g ; s/<\\/testsuites>/<\\/testsuite>/g' tests/testthat/junit.xml + fi + cp tests/testthat/junit.xml $WORKSPACE/results/ || true + sed -i 's/[^[:print:]\t]/�/g' $WORKSPACE/results/*.xml || true + ''' +} + +// very important, do not remove +// required so that Jenkins finds the methods defined in this script +return this diff --git a/src/main/resources/templates/r/exercise/DESCRIPTION b/src/main/resources/templates/r/exercise/DESCRIPTION new file mode 100644 index 000000000000..2933cb767621 --- /dev/null +++ b/src/main/resources/templates/r/exercise/DESCRIPTION @@ -0,0 +1,7 @@ +Package: assignment +Title: Artemis R Student Assignment +Version: 0.0.0.9000 +Author: Artemis +Description: This is an assignment to be solved by students. +License: MIT +Encoding: UTF-8 diff --git a/src/main/resources/templates/r/exercise/NAMESPACE b/src/main/resources/templates/r/exercise/NAMESPACE new file mode 100644 index 000000000000..9c9f9ac2d917 --- /dev/null +++ b/src/main/resources/templates/r/exercise/NAMESPACE @@ -0,0 +1 @@ +exportPattern("^[^\\.]") diff --git a/src/main/resources/templates/r/exercise/R/convert.R b/src/main/resources/templates/r/exercise/R/convert.R new file mode 100644 index 000000000000..28e787cf2967 --- /dev/null +++ b/src/main/resources/templates/r/exercise/R/convert.R @@ -0,0 +1,3 @@ +matrix_to_column_list <- function(mat) { + # TODO: implement +} diff --git a/src/main/resources/templates/r/readme b/src/main/resources/templates/r/readme new file mode 100644 index 000000000000..73377139d293 --- /dev/null +++ b/src/main/resources/templates/r/readme @@ -0,0 +1,6 @@ +# Matrix Columns + +Write a function `matrix_to_column_list` in R that takes a matrix of any shape and converts it into a list of +column-vectors. Each element of the list should represent a column of the matrix. + +1. [task][Convert to column-vectors](converts_3x3_matrix_to_vectors,converts_4x2_matrix_to_vectors,converts_1x5_matrix_to_scalars,converts_5x1_matrix_to_vector) diff --git a/src/main/resources/templates/r/solution/DESCRIPTION b/src/main/resources/templates/r/solution/DESCRIPTION new file mode 100644 index 000000000000..2933cb767621 --- /dev/null +++ b/src/main/resources/templates/r/solution/DESCRIPTION @@ -0,0 +1,7 @@ +Package: assignment +Title: Artemis R Student Assignment +Version: 0.0.0.9000 +Author: Artemis +Description: This is an assignment to be solved by students. +License: MIT +Encoding: UTF-8 diff --git a/src/main/resources/templates/r/solution/NAMESPACE b/src/main/resources/templates/r/solution/NAMESPACE new file mode 100644 index 000000000000..9c9f9ac2d917 --- /dev/null +++ b/src/main/resources/templates/r/solution/NAMESPACE @@ -0,0 +1 @@ +exportPattern("^[^\\.]") diff --git a/src/main/resources/templates/r/solution/R/convert.R b/src/main/resources/templates/r/solution/R/convert.R new file mode 100644 index 000000000000..7d701772ab7b --- /dev/null +++ b/src/main/resources/templates/r/solution/R/convert.R @@ -0,0 +1,17 @@ +matrix_to_column_list <- function(mat) { + if (!is.matrix(mat)) { + stop("Input must be a matrix") + } + + n_cols <- ncol(mat) + + # Initialize an empty list to store column-vectors + column_list <- vector("list", length = n_cols) + + # Loop through each column and store it in the list + for (i in 1:n_cols) { + column_list[[i]] <- mat[, i] + } + + return(column_list) +} diff --git a/src/main/resources/templates/r/test/DESCRIPTION b/src/main/resources/templates/r/test/DESCRIPTION new file mode 100644 index 000000000000..e19a2b735419 --- /dev/null +++ b/src/main/resources/templates/r/test/DESCRIPTION @@ -0,0 +1,14 @@ +Package: test +Title: Artemis R Tests +Version: 0.0.0.9000 +Author: Artemis +Description: This package tests the student assignment. +License: MIT +Encoding: UTF-8 +Imports: + assignment +Remotes: + local::./assignment +Suggests: + testthat (>= 3.0.0) +Config/testthat/edition: 3 diff --git a/src/main/resources/templates/r/test/tests/testthat.R b/src/main/resources/templates/r/test/tests/testthat.R new file mode 100644 index 000000000000..388438828173 --- /dev/null +++ b/src/main/resources/templates/r/test/tests/testthat.R @@ -0,0 +1,12 @@ +# This file is part of the standard setup for testthat. +# It is recommended that you do not modify it. +# +# Where should you do additional test configuration? +# Learn more about the roles of various files in: +# * https://r-pkgs.org/testing-design.html#sec-tests-files-overview +# * https://testthat.r-lib.org/articles/special-files.html + +library(testthat) +library(tests) + +test_check("tests") diff --git a/src/main/resources/templates/r/test/tests/testthat/test-convert.R b/src/main/resources/templates/r/test/tests/testthat/test-convert.R new file mode 100644 index 000000000000..a84a0e879711 --- /dev/null +++ b/src/main/resources/templates/r/test/tests/testthat/test-convert.R @@ -0,0 +1,47 @@ +test_that("converts_3x3_matrix_to_vectors", { + mat <- matrix(c(5, 8, 11, 6, 9, 12, 7, 10, 13), nrow = 3, ncol = 3) + + result <- assignment::matrix_to_column_list(mat) + + # Make sure to only use exactly one "expect_" function per test + expect_equal(result, list( + c(5, 8, 11), + c(6, 9, 12), + c(7, 10, 13) + )) +}) + +test_that("converts_4x2_matrix_to_vectors", { + mat <- matrix(c(13, 13, 5, 18, 11, 4, 7, 10), nrow = 4, ncol = 2) + + result <- assignment::matrix_to_column_list(mat) + + expect_equal(result, list( + c(13, 13, 5, 18), + c(11, 4, 7, 10) + )) +}) + +test_that("converts_1x5_matrix_to_scalars", { + mat <- matrix(c(16, 10, 15, 8, 7), nrow = 1, ncol = 5) + + result <- assignment::matrix_to_column_list(mat) + + expect_equal(result, list( + 16, + 10, + 15, + 8, + 7 + )) +}) + +test_that("converts_5x1_matrix_to_vector", { + mat <- matrix(c(14, 9, 1, 3, 4), nrow = 5, ncol = 1) + + result <- assignment::matrix_to_column_list(mat) + + expect_equal(result, list( + c(14, 9, 1, 3, 4) + )) +}) diff --git a/src/main/webapp/app/entities/programming/programming-exercise.model.ts b/src/main/webapp/app/entities/programming/programming-exercise.model.ts index ef2d95985068..17d04e971160 100644 --- a/src/main/webapp/app/entities/programming/programming-exercise.model.ts +++ b/src/main/webapp/app/entities/programming/programming-exercise.model.ts @@ -13,18 +13,19 @@ import { SubmissionPolicy } from 'app/entities/submission-policy.model'; import dayjs from 'dayjs/esm'; export enum ProgrammingLanguage { - JAVA = 'JAVA', - PYTHON = 'PYTHON', + EMPTY = 'EMPTY', + ASSEMBLER = 'ASSEMBLER', C = 'C', HASKELL = 'HASKELL', + JAVA = 'JAVA', + JAVASCRIPT = 'JAVASCRIPT', KOTLIN = 'KOTLIN', - VHDL = 'VHDL', - ASSEMBLER = 'ASSEMBLER', - SWIFT = 'SWIFT', OCAML = 'OCAML', - EMPTY = 'EMPTY', + PYTHON = 'PYTHON', + R = 'R', RUST = 'RUST', - JAVASCRIPT = 'JAVASCRIPT', + SWIFT = 'SWIFT', + VHDL = 'VHDL', } export enum ProjectType { diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/file-browser/supported-file-extensions.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/file-browser/supported-file-extensions.ts index 73ec34bebb00..89664e7f5963 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/file-browser/supported-file-extensions.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/file-browser/supported-file-extensions.ts @@ -1,5 +1,6 @@ export const supportedTextFileExtensions = [ 'Makefile', + 'R', 'Rakefile', 'ada', 'adb', diff --git a/src/test/resources/config/application.yml b/src/test/resources/config/application.yml index f48155253d32..ee94c5be7573 100644 --- a/src/test/resources/config/application.yml +++ b/src/test/resources/config/application.yml @@ -70,6 +70,8 @@ artemis: default: "~~invalid~~" javascript: default: "~~invalid~~" + r: + default: "~~invalid~~" spring: application: