@if (!isCommunicationPage) {
@if (sortedAnswerPosts.length) {
diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/emoji.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/emoji.action.ts
new file mode 100644
index 000000000000..20aa6d30da1b
--- /dev/null
+++ b/src/main/webapp/app/shared/monaco-editor/model/actions/emoji.action.ts
@@ -0,0 +1,121 @@
+import { TextEditorAction } from 'app/shared/monaco-editor/model/actions/text-editor-action.model';
+import { faSmile } from '@fortawesome/free-solid-svg-icons';
+import { TextEditor } from 'app/shared/monaco-editor/model/actions/adapter/text-editor.interface';
+import { ViewContainerRef } from '@angular/core';
+import { EmojiPickerComponent } from 'app/shared/metis/emoji/emoji-picker.component';
+import { Overlay, OverlayPositionBuilder, OverlayRef } from '@angular/cdk/overlay';
+import { ComponentPortal } from '@angular/cdk/portal';
+import { TextEditorPosition } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-position.model';
+import { TextEditorRange } from 'app/shared/monaco-editor/model/actions/adapter/text-editor-range.model';
+
+/**
+ * Action to open the emoji picker and insert the selected emoji into the editor.
+ */
+export class EmojiAction extends TextEditorAction {
+ static readonly ID = 'emoji.action';
+ private overlayRef: OverlayRef | null = null;
+ private position?: { x: number; y: number };
+
+ constructor(
+ private viewContainerRef: ViewContainerRef,
+ private overlay: Overlay,
+ private positionBuilder: OverlayPositionBuilder,
+ ) {
+ super(EmojiAction.ID, 'artemisApp.multipleChoiceQuestion.editor.emoji', faSmile, undefined);
+ }
+
+ /**
+ * Sets the position where the emoji picker should appear.
+ * @param param The {x, y} coordinates.
+ */
+ setPoint(param: { x: number; y: number }): void {
+ this.position = { x: param.x, y: param.y };
+ }
+
+ /**
+ * Triggers the opening of the emoji picker and attaches it to the view container.
+ * @param editor The editor in which to insert the emoji.
+ */
+ run(editor: TextEditor): void {
+ if (this.overlayRef) {
+ this.destroyEmojiPicker();
+ return;
+ }
+
+ if (this.position) {
+ this.createEmojiPicker(editor, this.position);
+ }
+ }
+
+ /**
+ * Creates and attaches the emoji picker component dynamically and handles emoji selection.
+ * @param editor The editor instance where the emoji will be inserted.
+ * @param position The {x, y} coordinates where the picker should appear.
+ */
+ private createEmojiPicker(editor: TextEditor, position: { x: number; y: number }): void {
+ const positionStrategy = this.positionBuilder
+ .global()
+ .left(`${position.x - 15}px`)
+ .top(`${position.y - 15}px`);
+
+ this.overlayRef = this.overlay.create({
+ positionStrategy,
+ hasBackdrop: true,
+ backdropClass: 'cdk-overlay-transparent-backdrop',
+ scrollStrategy: this.overlay.scrollStrategies.reposition(),
+ width: '0',
+ });
+
+ const emojiPickerPortal = new ComponentPortal(EmojiPickerComponent, this.viewContainerRef);
+ const componentRef = this.overlayRef.attach(emojiPickerPortal);
+ const pickerElement = componentRef.location.nativeElement;
+ pickerElement.style.transform = 'translate(-100%, -100%)';
+
+ componentRef.instance.emojiSelect.subscribe((selection: { emoji: any; event: PointerEvent }) => {
+ this.insertEmojiAtCursor(editor, selection.emoji.native);
+ this.destroyEmojiPicker();
+ });
+
+ this.overlayRef.backdropClick().subscribe(() => {
+ this.destroyEmojiPicker();
+ });
+ }
+
+ /**
+ * Inserts the selected emoji into the editor at the current cursor position.
+ * @param editor The editor instance.
+ * @param emoji The emoji to insert.
+ */
+ insertEmojiAtCursor(editor: TextEditor, emoji: string): void {
+ const position = editor.getPosition();
+ if (!position) {
+ return;
+ }
+
+ this.insertTextAtPosition(editor, position, emoji);
+
+ const newPosition = new TextEditorPosition(position.getLineNumber(), position.getColumn() + 2);
+ editor.setPosition(newPosition);
+ editor.focus();
+ }
+
+ /**
+ * Inserts the given emoji text at the current cursor position.
+ * @param editor The editor instance.
+ * @param position The current cursor position.
+ * @param emoji The emoji text to insert.
+ */
+ insertTextAtPosition(editor: TextEditor, position: TextEditorPosition, emoji: string): void {
+ this.replaceTextAtRange(editor, new TextEditorRange(position, position), emoji);
+ }
+
+ /**
+ * Destroys the emoji picker component after an emoji is selected or toggled.
+ */
+ private destroyEmojiPicker(): void {
+ if (this.overlayRef) {
+ this.overlayRef.dispose();
+ this.overlayRef = null;
+ }
+ }
+}
diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts
index 1237451e15bb..32889aef0f45 100644
--- a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts
+++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts
@@ -13,6 +13,8 @@ import { MonacoTextEditorAdapter } from 'app/shared/monaco-editor/model/actions/
import { MonacoEditorService } from 'app/shared/monaco-editor/monaco-editor.service';
import { getOS } from 'app/shared/util/os-detector.util';
+import { EmojiConvertor } from 'emoji-js';
+
export const MAX_TAB_SIZE = 8;
@Component({
@@ -34,6 +36,7 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
private readonly _editor: monaco.editor.IStandaloneCodeEditor;
private readonly textEditorAdapter: MonacoTextEditorAdapter;
private readonly monacoEditorContainerElement: HTMLElement;
+ private readonly emojiConvertor = new EmojiConvertor();
/*
* Elements, models, and actions of the editor.
@@ -86,6 +89,9 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
this.textEditorAdapter = new MonacoTextEditorAdapter(this._editor);
this.renderer.appendChild(this.elementRef.nativeElement, this.monacoEditorContainerElement);
+ this.emojiConvertor.replace_mode = 'unified';
+ this.emojiConvertor.allow_native = true;
+
effect(() => {
// TODO: The CSS class below allows the editor to shrink in the CodeEditorContainerComponent. We should eventually remove this class and handle the editor size differently in the code editor grid.
if (this.shrinkToFit()) {
@@ -103,6 +109,10 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
});
}
+ convertTextToEmoji(text: string): string {
+ return this.emojiConvertor.replace_emoticons(text);
+ }
+
ngOnInit(): void {
const resizeObserver = new ResizeObserver(() => {
this._editor.layout();
@@ -173,9 +183,18 @@ export class MonacoEditorComponent implements OnInit, OnDestroy {
return this._editor.getContentHeight() + this._editor.getOption(monaco.editor.EditorOption.lineHeight);
}
+ isConvertedToEmoji(originalText: string, convertedText: string): boolean {
+ return originalText !== convertedText;
+ }
+
setText(text: string): void {
- if (this.getText() !== text) {
- this._editor.setValue(text);
+ const convertedText = this.convertTextToEmoji(text);
+ if (this.isConvertedToEmoji(text, convertedText)) {
+ this._editor.setValue(convertedText);
+ this.setPosition({ column: this.getPosition().column + 2 + text.length, lineNumber: this.getPosition().lineNumber });
+ }
+ if (this.getText() !== convertedText) {
+ this._editor.setValue(convertedText);
}
}
diff --git a/src/main/webapp/app/shared/pipes/html-for-markdown.pipe.ts b/src/main/webapp/app/shared/pipes/html-for-markdown.pipe.ts
index 610faf20c0ca..660a5c0971dc 100644
--- a/src/main/webapp/app/shared/pipes/html-for-markdown.pipe.ts
+++ b/src/main/webapp/app/shared/pipes/html-for-markdown.pipe.ts
@@ -1,7 +1,7 @@
import { Pipe, PipeTransform } from '@angular/core';
-import { ShowdownExtension } from 'showdown';
import { SafeHtml } from '@angular/platform-browser';
import { ArtemisMarkdownService } from 'app/shared/markdown.service';
+import type { PluginSimple } from 'markdown-it';
@Pipe({
name: 'htmlForMarkdown',
@@ -12,14 +12,14 @@ export class HtmlForMarkdownPipe implements PipeTransform {
/**
* Converts markdown into html, sanitizes it and then declares it as safe to bypass further security.
* @param {string} markdown the original markdown text
- * @param {ShowdownExtension[]} extensions to use for markdown parsing
+ * @param {PluginSimple[]} extensions to use for markdown parsing
* @param {string[]} allowedHtmlTags to allow during sanitization
* @param {string[]} allowedHtmlAttributes to allow during sanitization
* @returns {string} the resulting html as a SafeHtml object that can be inserted into the angular template
*/
transform(
markdown?: string,
- extensions: ShowdownExtension[] = [],
+ extensions: PluginSimple[] = [],
allowedHtmlTags: string[] | undefined = undefined,
allowedHtmlAttributes: string[] | undefined = undefined,
): SafeHtml {
diff --git a/src/main/webapp/app/shared/util/markdown.conversion.util.ts b/src/main/webapp/app/shared/util/markdown.conversion.util.ts
index 658574db088b..d5166b70d690 100644
--- a/src/main/webapp/app/shared/util/markdown.conversion.util.ts
+++ b/src/main/webapp/app/shared/util/markdown.conversion.util.ts
@@ -1,24 +1,41 @@
-import showdown from 'showdown';
-import showdownKatex from 'showdown-katex';
-import showdownHighlight from 'showdown-highlight';
+import { ArtemisTextReplacementPlugin } from 'app/shared/markdown-editor/extensions/ArtemisTextReplacementPlugin';
import DOMPurify, { Config } from 'dompurify';
+import type { PluginSimple } from 'markdown-it';
+import markdownIt from 'markdown-it';
+import markdownItClass from 'markdown-it-class';
+import markdownItKatex from '@vscode/markdown-it-katex';
+import markdownItHighlightjs from 'markdown-it-highlightjs';
+import TurndownService from 'turndown';
/**
- * showdown will add the classes to the converted html
- * see: https://github.com/showdownjs/showdown/wiki/Add-default-classes-for-each-HTML-element
+ * Add these classes to the converted html.
*/
const classMap: { [key: string]: string } = {
table: 'table',
};
-/**
- * extension to add css classes to html tags
- * see: https://github.com/showdownjs/showdown/wiki/Add-default-classes-for-each-HTML-element
- */
-export const addCSSClass = Object.keys(classMap).map((key) => ({
- type: 'output',
- regex: new RegExp(`<${key}(.*)>`, 'g'),
- replace: `<${key} class="${classMap[key]}" $1>`,
-}));
+
+// An inline math formula has some other characters before or after the formula and uses $$ as delimiters
+const inlineFormulaRegex = /(?:.+\$\$[^\$]+\$\$)|(?:\$\$[^\$]+\$\$.+)/g;
+
+class FormulaCompatibilityPlugin extends ArtemisTextReplacementPlugin {
+ replaceText(text: string): string {
+ return text
+ .split('\n')
+ .map((line) => {
+ if (line.match(inlineFormulaRegex)) {
+ line = line.replace(/\$\$/g, '$');
+ }
+ if (line.includes('\\\\begin') || line.includes('\\\\end')) {
+ line = line.replaceAll('\\\\begin', '\\begin').replaceAll('\\\\end', '\\end');
+ }
+ return line;
+ })
+ .join('\n');
+ }
+}
+const formulaCompatibilityPlugin = new FormulaCompatibilityPlugin();
+
+const turndownService = new TurndownService();
/**
* Converts markdown into html (string) and sanitizes it. Does NOT declare it as safe to bypass further security
@@ -32,24 +49,37 @@ export const addCSSClass = Object.keys(classMap).map((key) => ({
*/
export function htmlForMarkdown(
markdownText?: string,
- extensions: showdown.ShowdownExtension[] = [],
+ extensions: PluginSimple[] = [],
allowedHtmlTags: string[] | undefined = undefined,
allowedHtmlAttributes: string[] | undefined = undefined,
): string {
if (!markdownText || markdownText === '') {
return '';
}
- const converter = new showdown.Converter({
- parseImgDimensions: true,
- headerLevelStart: 3,
- simplifiedAutoLink: true,
- strikethrough: true,
- tables: true,
- openLinksInNewWindow: true,
- backslashEscapesHTMLTags: true,
- extensions: [...extensions, showdownKatex(), showdownHighlight({ pre: true }), ...addCSSClass],
+
+ const md = markdownIt({
+ html: true,
+ linkify: true,
+ breaks: false, // Avoid line breaks after tasks
});
- const html = converter.makeHtml(markdownText);
+ for (const extension of extensions) {
+ md.use(extension);
+ }
+
+ // Add default extensions (Code Highlight, Latex)
+ md.use(markdownItHighlightjs)
+ .use(formulaCompatibilityPlugin.getExtension())
+ .use(markdownItKatex, {
+ enableMathInlineInHtml: true,
+ })
+ .use(markdownItClass, classMap);
+ let markdownRender = md.render(markdownText);
+ if (markdownRender.endsWith('\n')) {
+ // Keep legacy behavior from showdown where the output does not end with \n.
+ // This is needed because e.g. for quiz questions, we render the markdown in multiple small parts and then concatenate them.
+ markdownRender = markdownRender.slice(0, -1);
+ }
+
const purifyParameters = {} as Config;
// Prevents sanitizer from deleting
id
purifyParameters['ADD_TAGS'] = ['testid'];
@@ -59,18 +89,9 @@ export function htmlForMarkdown(
if (allowedHtmlAttributes) {
purifyParameters['ALLOWED_ATTR'] = allowedHtmlAttributes;
}
- return DOMPurify.sanitize(html, purifyParameters) as string;
+ return DOMPurify.sanitize(markdownRender, purifyParameters) as string;
}
export function markdownForHtml(htmlText: string): string {
- const converter = new showdown.Converter({
- parseImgDimensions: true,
- headerLevelStart: 3,
- simplifiedAutoLink: true,
- strikethrough: true,
- tables: true,
- openLinksInNewWindow: true,
- backslashEscapesHTMLTags: true,
- });
- return converter.makeMarkdown(htmlText);
+ return turndownService.turndown(htmlText);
}
diff --git a/src/main/webapp/i18n/de/multipleChoiceQuestion.json b/src/main/webapp/i18n/de/multipleChoiceQuestion.json
index 35c48898f228..9ca9f252b067 100644
--- a/src/main/webapp/i18n/de/multipleChoiceQuestion.json
+++ b/src/main/webapp/i18n/de/multipleChoiceQuestion.json
@@ -45,7 +45,8 @@
"style": "Ãœberschriften",
"codeBlock": "Code Block",
"code": "Code",
- "color": "Farbe"
+ "color": "Farbe",
+ "emoji": "Emoji"
},
"visualEditor": {
"hintTooltip": "Füge hier einen Hinweis hinzu (sichtbar während des Quiz über den ?-Button)",
diff --git a/src/main/webapp/i18n/en/multipleChoiceQuestion.json b/src/main/webapp/i18n/en/multipleChoiceQuestion.json
index f342c31a56bc..c39e3d24fc22 100644
--- a/src/main/webapp/i18n/en/multipleChoiceQuestion.json
+++ b/src/main/webapp/i18n/en/multipleChoiceQuestion.json
@@ -45,7 +45,8 @@
"codeBlock": "Code Block",
"code": "Code",
"color": "Color",
- "style": "Style"
+ "style": "Style",
+ "emoji": "Emoji"
},
"visualEditor": {
"hintTooltip": "Add a hint here (visible during the quiz via ?-Button)",
diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/AbstractAtlasIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/AbstractAtlasIntegrationTest.java
index c5a5e7fb07d1..71fa3201a4fe 100644
--- a/src/test/java/de/tum/cit/aet/artemis/atlas/AbstractAtlasIntegrationTest.java
+++ b/src/test/java/de/tum/cit/aet/artemis/atlas/AbstractAtlasIntegrationTest.java
@@ -14,7 +14,6 @@
import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository;
import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository;
import de.tum.cit.aet.artemis.atlas.repository.KnowledgeAreaRepository;
-import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository;
import de.tum.cit.aet.artemis.atlas.repository.ScienceSettingRepository;
import de.tum.cit.aet.artemis.atlas.repository.SourceRepository;
import de.tum.cit.aet.artemis.atlas.repository.StandardizedCompetencyRepository;
@@ -23,6 +22,7 @@
import de.tum.cit.aet.artemis.atlas.test_repository.CompetencyLectureUnitLinkTestRepository;
import de.tum.cit.aet.artemis.atlas.test_repository.CompetencyProgressTestRepository;
import de.tum.cit.aet.artemis.atlas.test_repository.LearningPathTestRepository;
+import de.tum.cit.aet.artemis.atlas.test_repository.PrerequisiteTestRepository;
import de.tum.cit.aet.artemis.atlas.test_repository.ScienceEventTestRepository;
import de.tum.cit.aet.artemis.core.service.feature.FeatureToggleService;
import de.tum.cit.aet.artemis.core.util.PageableSearchUtilService;
@@ -76,7 +76,7 @@ public abstract class AbstractAtlasIntegrationTest extends AbstractSpringIntegra
protected ScienceEventTestRepository scienceEventRepository;
@Autowired
- protected PrerequisiteRepository prerequisiteRepository;
+ protected PrerequisiteTestRepository prerequisiteRepository;
@Autowired
protected CompetencyJolRepository competencyJolRepository;
diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/competency/util/PrerequisiteUtilService.java b/src/test/java/de/tum/cit/aet/artemis/atlas/competency/util/PrerequisiteUtilService.java
index edf6e9378286..ac23fe4ef531 100644
--- a/src/test/java/de/tum/cit/aet/artemis/atlas/competency/util/PrerequisiteUtilService.java
+++ b/src/test/java/de/tum/cit/aet/artemis/atlas/competency/util/PrerequisiteUtilService.java
@@ -7,7 +7,7 @@
import org.springframework.stereotype.Service;
import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite;
-import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository;
+import de.tum.cit.aet.artemis.atlas.test_repository.PrerequisiteTestRepository;
import de.tum.cit.aet.artemis.core.domain.Course;
/**
@@ -17,7 +17,7 @@
public class PrerequisiteUtilService {
@Autowired
- private PrerequisiteRepository prerequisiteRepository;
+ private PrerequisiteTestRepository prerequisiteRepository;
/**
* Creates and saves a Prerequisite competency for the given Course.
diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/test_repository/PrerequisiteTestRepository.java b/src/test/java/de/tum/cit/aet/artemis/atlas/test_repository/PrerequisiteTestRepository.java
new file mode 100644
index 000000000000..a3a8e54cbe3b
--- /dev/null
+++ b/src/test/java/de/tum/cit/aet/artemis/atlas/test_repository/PrerequisiteTestRepository.java
@@ -0,0 +1,19 @@
+package de.tum.cit.aet.artemis.atlas.test_repository;
+
+import java.util.List;
+
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Repository;
+
+import de.tum.cit.aet.artemis.atlas.domain.competency.Prerequisite;
+import de.tum.cit.aet.artemis.atlas.repository.PrerequisiteRepository;
+
+/**
+ * Spring Data JPA repository for the {@link Prerequisite} entity.
+ */
+@Repository
+@Primary
+public interface PrerequisiteTestRepository extends PrerequisiteRepository {
+
+ List
findAllByCourseIdOrderById(long courseId);
+}
diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/AbstractProgrammingIntegrationIndependentTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/AbstractProgrammingIntegrationIndependentTest.java
index 46749fbcd3ec..21791e5d24f7 100644
--- a/src/test/java/de/tum/cit/aet/artemis/programming/AbstractProgrammingIntegrationIndependentTest.java
+++ b/src/test/java/de/tum/cit/aet/artemis/programming/AbstractProgrammingIntegrationIndependentTest.java
@@ -25,7 +25,6 @@
import de.tum.cit.aet.artemis.programming.repository.hestia.ExerciseHintActivationRepository;
import de.tum.cit.aet.artemis.programming.repository.hestia.ExerciseHintRepository;
import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseSolutionEntryRepository;
-import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseTaskRepository;
import de.tum.cit.aet.artemis.programming.repository.hestia.TestwiseCoverageReportEntryRepository;
import de.tum.cit.aet.artemis.programming.repository.settings.IdeRepository;
import de.tum.cit.aet.artemis.programming.repository.settings.UserIdeMappingRepository;
@@ -38,6 +37,7 @@
import de.tum.cit.aet.artemis.programming.service.hestia.ExerciseHintService;
import de.tum.cit.aet.artemis.programming.service.hestia.ProgrammingExerciseTaskService;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseStudentParticipationTestRepository;
+import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTaskTestRepository;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestCaseTestRepository;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestRepository;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingSubmissionTestRepository;
@@ -79,7 +79,7 @@ public abstract class AbstractProgrammingIntegrationIndependentTest extends Abst
protected ProgrammingExerciseStudentParticipationTestRepository programmingExerciseStudentParticipationRepository;
@Autowired
- protected ProgrammingExerciseTaskRepository taskRepository;
+ protected ProgrammingExerciseTaskTestRepository taskRepository;
@Autowired
protected ProgrammingExerciseTestCaseTestRepository testCaseRepository;
diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/test_repository/ProgrammingExerciseTaskTestRepository.java b/src/test/java/de/tum/cit/aet/artemis/programming/test_repository/ProgrammingExerciseTaskTestRepository.java
new file mode 100644
index 000000000000..7d79da2bdcb6
--- /dev/null
+++ b/src/test/java/de/tum/cit/aet/artemis/programming/test_repository/ProgrammingExerciseTaskTestRepository.java
@@ -0,0 +1,52 @@
+package de.tum.cit.aet.artemis.programming.test_repository;
+
+import java.util.Optional;
+import java.util.Set;
+
+import jakarta.validation.constraints.NotNull;
+
+import org.springframework.context.annotation.Primary;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException;
+import de.tum.cit.aet.artemis.programming.domain.hestia.ProgrammingExerciseTask;
+import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseTaskRepository;
+
+/**
+ * Spring Data repository for the ProgrammingExerciseTask entity.
+ */
+@Repository
+@Primary
+public interface ProgrammingExerciseTaskTestRepository extends ProgrammingExerciseTaskRepository {
+
+ Set findByExerciseId(Long exerciseId);
+
+ /**
+ * Gets a task with its programming exercise, test cases and solution entries of the test cases
+ *
+ * @param entryId The id of the task
+ * @return The task with the given ID if found
+ * @throws EntityNotFoundException If no task with the given ID was found
+ */
+ @NotNull
+ default ProgrammingExerciseTask findByIdWithTestCaseAndSolutionEntriesElseThrow(long entryId) throws EntityNotFoundException {
+ return getValueElseThrow(findByIdWithTestCaseAndSolutionEntries(entryId), entryId);
+ }
+
+ /**
+ * Gets a task with its programming exercise, test cases and solution entries of the test cases
+ *
+ * @param entryId The id of the task
+ * @return The task with the given ID
+ */
+ @Query("""
+ SELECT t
+ FROM ProgrammingExerciseTask t
+ LEFT JOIN FETCH t.testCases tc
+ LEFT JOIN FETCH tc.solutionEntries
+ WHERE t.id = :entryId
+ """)
+ Optional findByIdWithTestCaseAndSolutionEntries(@Param("entryId") long entryId);
+}
diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseTestService.java b/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseTestService.java
index 95ba2804b3b6..b8963f3decae 100644
--- a/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseTestService.java
+++ b/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseTestService.java
@@ -136,7 +136,6 @@
import de.tum.cit.aet.artemis.programming.repository.BuildPlanRepository;
import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseBuildConfigRepository;
import de.tum.cit.aet.artemis.programming.repository.StaticCodeAnalysisCategoryRepository;
-import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseTaskRepository;
import de.tum.cit.aet.artemis.programming.service.AutomaticProgrammingExerciseCleanupService;
import de.tum.cit.aet.artemis.programming.service.GitService;
import de.tum.cit.aet.artemis.programming.service.JavaTemplateUpgradeService;
@@ -148,6 +147,7 @@
import de.tum.cit.aet.artemis.programming.service.vcs.VersionControlRepositoryPermission;
import de.tum.cit.aet.artemis.programming.service.vcs.VersionControlService;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseStudentParticipationTestRepository;
+import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTaskTestRepository;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestCaseTestRepository;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestRepository;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingSubmissionTestRepository;
@@ -228,7 +228,7 @@ public class ProgrammingExerciseTestService {
private JavaTemplateUpgradeService javaTemplateUpgradeService;
@Autowired
- private ProgrammingExerciseTaskRepository programmingExerciseTaskRepository;
+ private ProgrammingExerciseTaskTestRepository programmingExerciseTaskRepository;
@Autowired
private ProgrammingExerciseTestCaseTestRepository programmingExerciseTestCaseRepository;
diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseUtilService.java b/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseUtilService.java
index e1571792335a..7aa1d99e1c8f 100644
--- a/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseUtilService.java
+++ b/src/test/java/de/tum/cit/aet/artemis/programming/util/ProgrammingExerciseUtilService.java
@@ -74,8 +74,8 @@
import de.tum.cit.aet.artemis.programming.repository.hestia.CodeHintRepository;
import de.tum.cit.aet.artemis.programming.repository.hestia.ExerciseHintRepository;
import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseSolutionEntryRepository;
-import de.tum.cit.aet.artemis.programming.repository.hestia.ProgrammingExerciseTaskRepository;
import de.tum.cit.aet.artemis.programming.service.GitService;
+import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTaskTestRepository;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestCaseTestRepository;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingExerciseTestRepository;
import de.tum.cit.aet.artemis.programming.test_repository.ProgrammingSubmissionTestRepository;
@@ -145,7 +145,7 @@ public class ProgrammingExerciseUtilService {
private ExerciseHintRepository exerciseHintRepository;
@Autowired
- private ProgrammingExerciseTaskRepository programmingExerciseTaskRepository;
+ private ProgrammingExerciseTaskTestRepository programmingExerciseTaskRepository;
@Autowired
private ProgrammingExerciseSolutionEntryRepository solutionEntryRepository;
diff --git a/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleRepositoryArchitectureTest.java b/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleRepositoryArchitectureTest.java
index 6e3887654163..fdc272e7877d 100644
--- a/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleRepositoryArchitectureTest.java
+++ b/src/test/java/de/tum/cit/aet/artemis/shared/architecture/module/AbstractModuleRepositoryArchitectureTest.java
@@ -20,6 +20,7 @@
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
@@ -48,6 +49,14 @@ void shouldBeNamedRepository() {
rule.allowEmptyShould(true).check(allClasses);
}
+ @Test
+ void shouldBeAnnotatedRepository() {
+ ArchRule rule = classesOfThisModuleThat().haveSimpleNameEndingWith("Repository").and().areInterfaces().should().beAnnotatedWith(Repository.class).orShould()
+ .beAnnotatedWith(NoRepositoryBean.class).because("repositories should be annotated with @Repository or @NoRepositoryBean.");
+ // allow empty should since some modules do not have repositories
+ rule.allowEmptyShould(true).check(allClasses);
+ }
+
@Test
void shouldBeInRepositoryPackage() {
ArchRule rule = classesOfThisModuleThat().areAnnotatedWith(Repository.class).should().resideInAPackage("..repository..")
diff --git a/src/test/javascript/spec/component/assessment-shared/assessment-header.component.spec.ts b/src/test/javascript/spec/component/assessment-shared/assessment-header.component.spec.ts
index a85e5e47bc16..aa0073961211 100644
--- a/src/test/javascript/spec/component/assessment-shared/assessment-header.component.spec.ts
+++ b/src/test/javascript/spec/component/assessment-shared/assessment-header.component.spec.ts
@@ -159,14 +159,14 @@ describe('AssessmentHeaderComponent', () => {
saveButtonSpan.nativeElement.click();
expect(component.save.emit).toHaveBeenCalledOnce();
- jest.spyOn(component.submit, 'emit');
+ jest.spyOn(component.onSubmit, 'emit');
submitButtonSpan.nativeElement.click();
- expect(component.submit.emit).toHaveBeenCalledOnce();
+ expect(component.onSubmit.emit).toHaveBeenCalledOnce();
const cancelButtonSpan = fixture.debugElement.query(By.css('[jhiTranslate$=cancel]'));
- jest.spyOn(component.cancel, 'emit');
+ jest.spyOn(component.onCancel, 'emit');
cancelButtonSpan.nativeElement.click();
- expect(component.cancel.emit).toHaveBeenCalledOnce();
+ expect(component.onCancel.emit).toHaveBeenCalledOnce();
});
it('should show override button when result is present', () => {
@@ -189,9 +189,9 @@ describe('AssessmentHeaderComponent', () => {
overrideAssessmentButtonSpan = fixture.debugElement.query(By.css('[jhiTranslate$=overrideAssessment]'));
expect(overrideAssessmentButtonSpan).toBeTruthy();
- jest.spyOn(component.submit, 'emit');
+ jest.spyOn(component.onSubmit, 'emit');
overrideAssessmentButtonSpan.nativeElement.click();
- expect(component.submit.emit).toHaveBeenCalledOnce();
+ expect(component.onSubmit.emit).toHaveBeenCalledOnce();
});
it('should show next submission if assessor or instructor, result is present and no complaint', () => {
@@ -345,7 +345,7 @@ describe('AssessmentHeaderComponent', () => {
const eventMock = new KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter' });
const spyOnControlAndEnter = jest.spyOn(component, 'submitOnControlAndEnter');
- const submitSpy = jest.spyOn(component.submit, 'emit');
+ const submitSpy = jest.spyOn(component.onSubmit, 'emit');
document.dispatchEvent(eventMock);
expect(spyOnControlAndEnter).toHaveBeenCalledOnce();
@@ -362,7 +362,7 @@ describe('AssessmentHeaderComponent', () => {
const eventMock = new KeyboardEvent('keydown', { ctrlKey: true, key: 'Enter' });
const spyOnControlAndEnter = jest.spyOn(component, 'submitOnControlAndEnter');
- const submitSpy = jest.spyOn(component.submit, 'emit');
+ const submitSpy = jest.spyOn(component.onSubmit, 'emit');
document.dispatchEvent(eventMock);
expect(spyOnControlAndEnter).toHaveBeenCalledOnce();
diff --git a/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts b/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts
index 6888c6d994ea..94e72e39d3de 100644
--- a/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts
+++ b/src/test/javascript/spec/component/competencies/generate-competencies/generate-competencies.component.spec.ts
@@ -259,11 +259,10 @@ describe('GenerateCompetenciesComponent', () => {
expect(warnMock).toHaveBeenCalled();
});
- it('should send a warning when trying to reload', () => {
+ it('should not deactivate when loading', () => {
generateCompetenciesComponent.isLoading = true;
- const event = { returnValue: undefined };
- generateCompetenciesComponent.unloadNotification(event);
- expect(event.returnValue).toBe(generateCompetenciesComponent.canDeactivateWarning);
+ const canDeactivate = generateCompetenciesComponent.canDeactivate();
+ expect(canDeactivate).toBeFalse();
});
function createCompetencyFormGroup(title?: string, description?: string, taxonomy?: CompetencyTaxonomy, viewed = false): FormGroup {
diff --git a/src/test/javascript/spec/component/competencies/import/import-course-competencies.component.spec.ts b/src/test/javascript/spec/component/competencies/import/import-course-competencies.component.spec.ts
index 3c92ae65d93c..619054449eba 100644
--- a/src/test/javascript/spec/component/competencies/import/import-course-competencies.component.spec.ts
+++ b/src/test/javascript/spec/component/competencies/import/import-course-competencies.component.spec.ts
@@ -181,16 +181,15 @@ describe('ImportCourseCompetenciesComponent', () => {
expect(component.disabledIds).toHaveLength(3);
});
- it('should not unload with pending changes', () => {
- const deactivateWarningSpy = jest.spyOn(component, 'canDeactivateWarning', 'get');
-
+ it('should not deactivate with pending changes', () => {
+ let canDeactivate;
component['isSubmitted'] = true;
component.selectedCourseCompetencies = { resultsOnPage: [{ id: 1 }], numberOfPages: 0 };
- component['unloadNotification']({ returnValue: '' });
- expect(deactivateWarningSpy).not.toHaveBeenCalled();
+ canDeactivate = component.canDeactivate();
+ expect(canDeactivate).toBeTrue();
component['isSubmitted'] = false;
- component['unloadNotification']({ returnValue: '' });
- expect(deactivateWarningSpy).toHaveBeenCalled();
+ canDeactivate = component.canDeactivate();
+ expect(canDeactivate).toBeFalse();
});
});
diff --git a/src/test/javascript/spec/component/complaints/complaints-form.component.spec.ts b/src/test/javascript/spec/component/complaints/complaints-form.component.spec.ts
index c0e144ca8828..488c740be038 100644
--- a/src/test/javascript/spec/component/complaints/complaints-form.component.spec.ts
+++ b/src/test/javascript/spec/component/complaints/complaints-form.component.spec.ts
@@ -84,7 +84,7 @@ describe('ComplaintsFormComponent', () => {
it('should submit after complaint creation', () => {
const createMock = jest.spyOn(complaintService, 'create').mockReturnValue(of({} as EntityResponseType));
- const submitSpy = jest.spyOn(component.submit, 'emit');
+ const submitSpy = jest.spyOn(component.onSubmit, 'emit');
component.createComplaint();
expect(createMock).toHaveBeenCalledOnce();
expect(submitSpy).toHaveBeenCalledOnce();
@@ -93,7 +93,7 @@ describe('ComplaintsFormComponent', () => {
it('should throw unknown error after complaint creation', () => {
const createMock = jest.spyOn(complaintService, 'create').mockReturnValue(throwError(() => ({ status: 400 })));
- const submitSpy = jest.spyOn(component.submit, 'emit');
+ const submitSpy = jest.spyOn(component.onSubmit, 'emit');
const errorSpy = jest.spyOn(alertService, 'error');
component.createComplaint();
expect(createMock).toHaveBeenCalledOnce();
@@ -104,7 +104,7 @@ describe('ComplaintsFormComponent', () => {
it('should throw known error after complaint creation', () => {
const error = { error: { errorKey: 'tooManyComplaints' } } as HttpErrorResponse;
const createMock = jest.spyOn(complaintService, 'create').mockReturnValue(throwError(() => error));
- const submitSpy = jest.spyOn(component.submit, 'emit');
+ const submitSpy = jest.spyOn(component.onSubmit, 'emit');
const errorSpy = jest.spyOn(alertService, 'error');
const numberOfComplaints = 42;
component.maxComplaintsPerCourse = numberOfComplaints;
@@ -120,7 +120,7 @@ describe('ComplaintsFormComponent', () => {
component.exercise = courseExercise;
component.ngOnInit();
- const submitSpy = jest.spyOn(component.submit, 'emit');
+ const submitSpy = jest.spyOn(component.onSubmit, 'emit');
const errorSpy = jest.spyOn(alertService, 'error');
// 26 characters
component.complaintText = 'abcdefghijklmnopqrstuvwxyz';
diff --git a/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts b/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts
index de433ab78169..1e4bda79525a 100644
--- a/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts
+++ b/src/test/javascript/spec/component/exam/participate/exam-participation.component.spec.ts
@@ -767,16 +767,6 @@ describe('ExamParticipationComponent', () => {
});
});
- describe('unloadNotification', () => {
- it('should set event return value', () => {
- jest.spyOn(comp, 'canDeactivate').mockReturnValue(false);
- jest.spyOn(comp, 'canDeactivateWarning', 'get').mockReturnValue('warning');
- const event = { returnValue: undefined };
- comp.unloadNotification(event);
- expect(event.returnValue).toBe('warning');
- });
- });
-
describe('isOver', () => {
it('should return true if exam has ended', () => {
const studentExam = new StudentExam();
diff --git a/src/test/javascript/spec/component/exercises/quiz/short-answer-question-util.service.spec.ts b/src/test/javascript/spec/component/exercises/quiz/short-answer-question-util.service.spec.ts
index da7e4949bbb3..47aeba9f7fd9 100644
--- a/src/test/javascript/spec/component/exercises/quiz/short-answer-question-util.service.spec.ts
+++ b/src/test/javascript/spec/component/exercises/quiz/short-answer-question-util.service.spec.ts
@@ -216,7 +216,7 @@ describe('ShortAnswerQuestionUtil', () => {
const originalTextParts2 = [['`random code`'], ['` some more code`', '[-spot 1]'], ['`last code paragraph`']];
const formattedTextParts2 = [
['random code
'],
- [' some more code
', '[-spot 1]
'],
+ [' some more code
', '[-spot 1]
'],
['last code paragraph
'],
];
expect(service.transformTextPartsIntoHTML(originalTextParts2)).toEqual(formattedTextParts2);
diff --git a/src/test/javascript/spec/component/lecture-unit/text-unit/text-unit.component.spec.ts b/src/test/javascript/spec/component/lecture-unit/text-unit/text-unit.component.spec.ts
index dc8ffa8669af..3326a8e91514 100644
--- a/src/test/javascript/spec/component/lecture-unit/text-unit/text-unit.component.spec.ts
+++ b/src/test/javascript/spec/component/lecture-unit/text-unit/text-unit.component.spec.ts
@@ -27,7 +27,7 @@ describe('TextUnitComponent', () => {
visibleToStudents: true,
};
- const exampleHtml = 'Sample Markdown
';
+ const exampleHtml = 'Sample Markdown
';
beforeEach(async () => {
await TestBed.configureTestingModule({
diff --git a/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-thread-sidebar/conversation-thread-sidebar.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-thread-sidebar/conversation-thread-sidebar.component.spec.ts
index 691cb37f749b..d2de1b99462d 100644
--- a/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-thread-sidebar/conversation-thread-sidebar.component.spec.ts
+++ b/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-thread-sidebar/conversation-thread-sidebar.component.spec.ts
@@ -52,4 +52,16 @@ describe('ConversationThreadSidebarComponent', () => {
component.activeConversation = conversation;
expect(component.hasChannelModerationRights).toBe(hasModerationRights);
});
+
+ it('should set min and max width for the resizable thread section', () => {
+ const expandedThreadElement = fixture.debugElement.nativeElement.querySelector('.expanded-thread');
+ const minWidth = window.innerWidth * 0.3;
+ const maxWidth = window.innerWidth;
+
+ expandedThreadElement.style.width = `${minWidth}px`;
+ expect(parseFloat(expandedThreadElement.style.width)).toBeGreaterThanOrEqual(minWidth);
+
+ expandedThreadElement.style.width = `${maxWidth}px`;
+ expect(parseFloat(expandedThreadElement.style.width)).toBeLessThanOrEqual(maxWidth);
+ });
});
diff --git a/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts b/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts
index a93c87bdf3f3..c271a8deb04e 100644
--- a/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts
+++ b/src/test/javascript/spec/component/quiz-exercise/quiz-exercise-update.component.spec.ts
@@ -633,18 +633,16 @@ describe('QuizExerciseUpdateComponent', () => {
expect(comp.canDeactivate()).toBeTrue();
});
- it('should set event return value to translate if not canDeactivate', () => {
+ it('should not deactivate with pending changes', () => {
comp.pendingChangesCache = true;
- const ev = { returnValue: undefined };
- comp.unloadNotification(ev);
- expect(ev.returnValue).toBe('pendingChanges');
+ const canDeactivate = comp.canDeactivate();
+ expect(canDeactivate).toBeFalse();
});
- it('should not set event return value to translate if canDeactivate', () => {
+ it('should deactivate with no pending changes', () => {
comp.pendingChangesCache = false;
- const ev = { returnValue: undefined };
- comp.unloadNotification(ev);
- expect(ev.returnValue).toBeUndefined();
+ const canDeactivate = comp.canDeactivate();
+ expect(canDeactivate).toBeTrue();
});
});
diff --git a/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts b/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts
index 81e073bf43a6..424567dd6518 100644
--- a/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts
+++ b/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts
@@ -94,7 +94,7 @@ describe('PostingContentPartComponent', () => {
expect(markdownRenderedTexts).toHaveLength(2);
// check that the paragraph right before the reference and the paragraph right after have the class `inline-paragraph`
expect(markdownRenderedTexts![0].innerHTML).toInclude('Be aware
');
- expect(markdownRenderedTexts![0].innerHTML).toInclude('I want to reference the following Post
'); // last paragraph before reference
+ expect(markdownRenderedTexts![0].innerHTML).toInclude('I want to reference the following Post
'); // last paragraph before reference
expect(markdownRenderedTexts![1].innerHTML).toInclude('in my content,
'); // first paragraph after reference
expect(markdownRenderedTexts![1].innerHTML).toInclude('does it actually work?
');
diff --git a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts
index e2195d845812..2cd9277355e3 100644
--- a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts
+++ b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts
@@ -8,8 +8,7 @@ import { MetisService } from 'app/shared/metis/metis.service';
import { MockMetisService } from '../../../../helpers/mocks/service/mock-metis-service.service';
import { metisAnswerPostUser2, metisPostExerciseUser1 } from '../../../../helpers/sample/metis-sample-data';
import { LectureService } from 'app/lecture/lecture.service';
-import { HttpResponse } from '@angular/common/http';
-import { of } from 'rxjs';
+import { Subject } from 'rxjs';
import { CourseManagementService } from 'app/course/manage/course-management.service';
import { ChannelService } from 'app/shared/metis/conversations/channel.service';
import * as CourseModel from 'app/entities/course.model';
@@ -26,6 +25,10 @@ import { ExerciseReferenceAction } from 'app/shared/monaco-editor/model/actions/
import { LectureAttachmentReferenceAction } from 'app/shared/monaco-editor/model/actions/communication/lecture-attachment-reference.action';
import { UrlAction } from '../../../../../../../main/webapp/app/shared/monaco-editor/model/actions/url.action';
import { AttachmentAction } from '../../../../../../../main/webapp/app/shared/monaco-editor/model/actions/attachment.action';
+import { EmojiAction } from 'app/shared/monaco-editor/model/actions/emoji.action';
+import { Overlay, OverlayPositionBuilder } from '@angular/cdk/overlay';
+import { TextEditor } from 'app/shared/monaco-editor/model/actions/adapter/text-editor.interface';
+import { ComponentPortal } from '@angular/cdk/portal';
describe('PostingsMarkdownEditor', () => {
let component: PostingMarkdownEditorComponent;
@@ -33,14 +36,78 @@ describe('PostingsMarkdownEditor', () => {
let debugElement: DebugElement;
let mockMarkdownEditorComponent: MarkdownEditorMonacoComponent;
let metisService: MetisService;
- let courseManagementService: CourseManagementService;
- let channelService: ChannelService;
let lectureService: LectureService;
- let findLectureWithDetailsSpy: jest.SpyInstance;
+
+ const backdropClickSubject = new Subject();
+ const mockOverlayRef = {
+ attach: jest.fn().mockReturnValue({ location: { nativeElement: document.createElement('div') } }),
+ backdropClick: jest.fn(() => backdropClickSubject.asObservable()),
+ detach: jest.fn(),
+ dispose: jest.fn(),
+ scrollStrategies: { reposition: jest.fn() },
+ };
+
+ const mockOverlay = {
+ create: jest.fn().mockReturnValue(mockOverlayRef),
+ scrollStrategies: { reposition: jest.fn().mockReturnValue({}) },
+ };
+
+ const mockEditor: TextEditor = {
+ getPosition: jest.fn(),
+ setPosition: jest.fn(),
+ focus: jest.fn(),
+ addAction: jest.fn(),
+ executeAction: jest.fn(),
+ layout: jest.fn(),
+ replaceTextAtRange: jest.fn(),
+ getDomNode: jest.fn(),
+ triggerCompletion: jest.fn(),
+ getTextAtRange: jest.fn(),
+ getLineText: jest.fn(),
+ getNumberOfLines: jest.fn(),
+ getEndPosition: jest.fn(),
+ getSelection: jest.fn(),
+ setSelection: jest.fn(),
+ revealRange: jest.fn(),
+ addCompleter: jest.fn(),
+ };
+
+ const mockPositionStrategy = {
+ left: jest.fn().mockReturnThis(),
+ top: jest.fn().mockReturnThis(),
+ };
+
+ const overlayPositionBuilderMock = {
+ global: jest.fn().mockReturnValue(mockPositionStrategy),
+ };
beforeEach(() => {
+ if (typeof PointerEvent === 'undefined') {
+ global.PointerEvent = class PointerEvent extends MouseEvent {
+ constructor(type: string, params: MouseEventInit = {}) {
+ super(type, params);
+ }
+ } as unknown as typeof PointerEvent;
+ }
+ const mockEmojiSelect = new Subject<{ emoji: any; event: PointerEvent }>();
+ const mockComponentRef = {
+ instance: {
+ emojiSelect: mockEmojiSelect.asObservable(),
+ },
+ location: { nativeElement: document.createElement('div') },
+ };
+
+ mockOverlayRef.attach.mockReturnValue(mockComponentRef);
+
return TestBed.configureTestingModule({
- providers: [{ provide: MetisService, useClass: MockMetisService }, MockProvider(LectureService), MockProvider(CourseManagementService), MockProvider(ChannelService)],
+ providers: [
+ { provide: MetisService, useClass: MockMetisService },
+ MockProvider(LectureService),
+ MockProvider(CourseManagementService),
+ MockProvider(ChannelService),
+ { provide: Overlay, useValue: mockOverlay },
+ { provide: OverlayPositionBuilder, useValue: overlayPositionBuilderMock },
+ ],
declarations: [PostingMarkdownEditorComponent, MockComponent(MarkdownEditorMonacoComponent)],
})
.compileComponents()
@@ -49,54 +116,57 @@ describe('PostingsMarkdownEditor', () => {
component = fixture.componentInstance;
debugElement = fixture.debugElement;
metisService = TestBed.inject(MetisService);
- courseManagementService = TestBed.inject(CourseManagementService);
lectureService = TestBed.inject(LectureService);
- channelService = TestBed.inject(ChannelService);
- findLectureWithDetailsSpy = jest.spyOn(lectureService, 'findAllByCourseIdWithSlides');
- const returnValue = of(new HttpResponse({ body: [], status: 200 }));
- findLectureWithDetailsSpy.mockReturnValue(returnValue);
fixture.autoDetectChanges();
mockMarkdownEditorComponent = fixture.debugElement.query(By.directive(MarkdownEditorMonacoComponent)).componentInstance;
component.ngOnInit();
component.content = metisPostExerciseUser1.content;
+
+ mockEmojiSelect.next({ emoji: { native: '😀' }, event: new PointerEvent('click') });
});
});
it('should have set the correct default commands on init if messaging or communication is enabled', () => {
component.ngOnInit();
- expect(component.defaultActions).toEqual([
- new BoldAction(),
- new ItalicAction(),
- new UnderlineAction(),
- new QuoteAction(),
- new CodeAction(),
- new CodeBlockAction(),
- new UrlAction(),
- new AttachmentAction(),
- new UserMentionAction(courseManagementService, metisService),
- new ChannelReferenceAction(metisService, channelService),
- new ExerciseReferenceAction(metisService),
- ]);
+ expect(component.defaultActions).toEqual(
+ expect.arrayContaining([
+ expect.any(BoldAction),
+ expect.any(ItalicAction),
+ expect.any(UnderlineAction),
+ expect.any(QuoteAction),
+ expect.any(CodeAction),
+ expect.any(CodeBlockAction),
+ expect.any(EmojiAction),
+ expect.any(UrlAction),
+ expect.any(AttachmentAction),
+ expect.any(UserMentionAction),
+ expect.any(ChannelReferenceAction),
+ expect.any(ExerciseReferenceAction),
+ ]),
+ );
expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService));
});
- it('should have set the correct default commands on init if communication and messaging and communication is disabled', () => {
+ it('should have set the correct default commands on init if communication is disabled', () => {
jest.spyOn(CourseModel, 'isCommunicationEnabled').mockReturnValueOnce(false);
component.ngOnInit();
- expect(component.defaultActions).toEqual([
- new BoldAction(),
- new ItalicAction(),
- new UnderlineAction(),
- new QuoteAction(),
- new CodeAction(),
- new CodeBlockAction(),
- new UrlAction(),
- new AttachmentAction(),
- new ExerciseReferenceAction(metisService),
- ]);
+ expect(component.defaultActions).toEqual(
+ expect.arrayContaining([
+ expect.any(BoldAction),
+ expect.any(ItalicAction),
+ expect.any(UnderlineAction),
+ expect.any(QuoteAction),
+ expect.any(CodeAction),
+ expect.any(CodeBlockAction),
+ expect.any(EmojiAction),
+ expect.any(UrlAction),
+ expect.any(AttachmentAction),
+ expect.any(ExerciseReferenceAction),
+ ]),
+ );
expect(component.lectureAttachmentReferenceAction).toEqual(new LectureAttachmentReferenceAction(metisService, lectureService));
});
@@ -171,4 +241,80 @@ describe('PostingsMarkdownEditor', () => {
component.onKeyDown(event);
expect(preventDefaultSpy).not.toHaveBeenCalled();
});
+
+ it('should not create emoji picker if position is not set', () => {
+ const emojiAction = new EmojiAction(component.viewContainerRef, mockOverlay as any, overlayPositionBuilderMock as any);
+ emojiAction.run(mockEditor);
+
+ expect(mockOverlay.create).not.toHaveBeenCalled();
+ expect(mockOverlayRef.attach).not.toHaveBeenCalled();
+ });
+
+ it('should attach EmojiPickerComponent to overlay when EmojiAction.run is called', () => {
+ const emojiAction = component.defaultActions.find((action) => action instanceof EmojiAction) as EmojiAction;
+ emojiAction.setPoint({ x: 100, y: 200 });
+
+ emojiAction.run(mockEditor);
+
+ expect(mockOverlayRef.attach).toHaveBeenCalledWith(expect.any(ComponentPortal));
+ expect(mockOverlayRef.backdropClick).toHaveBeenCalled();
+ });
+
+ it('should create overlay with correct position when EmojiAction.run is called', () => {
+ const emojiAction = component.defaultActions.find((action) => action instanceof EmojiAction) as EmojiAction;
+ emojiAction.setPoint({ x: 100, y: 200 });
+
+ const mockPositionStrategy = {
+ left: jest.fn().mockReturnThis(),
+ top: jest.fn().mockReturnThis(),
+ };
+
+ mockOverlay.create.mockReturnValue(mockOverlayRef);
+ overlayPositionBuilderMock.global.mockReturnValue(mockPositionStrategy);
+
+ emojiAction.run(mockEditor);
+
+ expect(mockOverlay.create).toHaveBeenCalledWith({
+ positionStrategy: mockPositionStrategy,
+ hasBackdrop: true,
+ backdropClass: 'cdk-overlay-transparent-backdrop',
+ scrollStrategy: mockOverlay.scrollStrategies.reposition(),
+ width: '0',
+ });
+
+ expect(mockPositionStrategy.left).toHaveBeenCalledWith(`85px`);
+ expect(mockPositionStrategy.top).toHaveBeenCalledWith(`185px`);
+ });
+
+ it('should detach overlay and close EmojiPickerComponent on backdrop click', () => {
+ const emojiAction = component.defaultActions.find((action) => action instanceof EmojiAction) as EmojiAction;
+ emojiAction.setPoint({ x: 100, y: 200 });
+
+ emojiAction.run(mockEditor);
+ backdropClickSubject.next();
+
+ expect(mockOverlayRef.dispose).toHaveBeenCalled();
+ });
+
+ it('should destroy emoji picker if it is already open', () => {
+ const emojiAction = new EmojiAction(component.viewContainerRef, mockOverlay as any, overlayPositionBuilderMock as any);
+
+ emojiAction['overlayRef'] = mockOverlayRef as any;
+ const destroySpy = jest.spyOn(emojiAction as any, 'destroyEmojiPicker');
+
+ emojiAction.run(mockEditor);
+
+ expect(destroySpy).toHaveBeenCalled();
+ expect(mockOverlayRef.dispose).toHaveBeenCalled();
+ });
+
+ it('should clean up overlay reference on destroy', () => {
+ const emojiAction = new EmojiAction(component.viewContainerRef, mockOverlay as any, overlayPositionBuilderMock as any);
+
+ emojiAction['overlayRef'] = mockOverlayRef as any;
+ emojiAction['destroyEmojiPicker']();
+
+ expect(mockOverlayRef.dispose).toHaveBeenCalled();
+ expect(emojiAction['overlayRef']).toBeNull();
+ });
});
diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts
index df228c9b340c..244e9004ac43 100644
--- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts
+++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts
@@ -14,6 +14,8 @@ describe('MonacoEditorComponent', () => {
const singleLineText = 'public class Main { }';
const multiLineText = ['public class Main {', 'static void main() {', 'foo();', '}', '}'].join('\n');
+ const textWithEmoticons = 'Hello :)';
+ const textWithEmojis = 'Hello 🙂';
const buildAnnotationArray: Annotation[] = [{ fileName: 'example.java', row: 1, column: 0, timestamp: 0, type: MonacoEditorBuildAnnotationType.ERROR, text: 'example error' }];
@@ -262,4 +264,29 @@ describe('MonacoEditorComponent', () => {
comp.applyOptionPreset(preset);
expect(applySpy).toHaveBeenCalledExactlyOnceWith(comp['_editor']);
});
+
+ it('should convert text emoticons to emojis using convertTextToEmoji', () => {
+ fixture.detectChanges();
+ const result = comp.convertTextToEmoji(textWithEmoticons);
+ expect(result).toBe(textWithEmojis);
+ });
+
+ it('should detect if text is converted to emoji using isConvertedToEmoji', () => {
+ fixture.detectChanges();
+ const isConverted = comp.isConvertedToEmoji(textWithEmoticons, textWithEmojis);
+ expect(isConverted).toBeTrue();
+
+ const notConverted = comp.isConvertedToEmoji(textWithEmojis, textWithEmojis);
+ expect(notConverted).toBeFalse();
+ });
+
+ it('should not change the editor text if no conversion is needed', () => {
+ fixture.detectChanges();
+ const originalText = 'Hello 😊';
+ comp.setText(originalText);
+ expect(comp.getText()).toBe(originalText);
+
+ comp.setText(originalText);
+ expect(comp.getText()).toBe(originalText);
+ });
});
diff --git a/src/test/javascript/spec/component/standardized-competencies/course-import-standardized-competencies.spec.ts b/src/test/javascript/spec/component/standardized-competencies/course-import-standardized-competencies.spec.ts
index c780c1c252c6..a32795a28d18 100644
--- a/src/test/javascript/spec/component/standardized-competencies/course-import-standardized-competencies.spec.ts
+++ b/src/test/javascript/spec/component/standardized-competencies/course-import-standardized-competencies.spec.ts
@@ -205,14 +205,14 @@ describe('CourseImportStandardizedCompetenciesComponent', () => {
});
it('should not deactivate with pending changes', () => {
- const deactivateWarningSpy = jest.spyOn(component as any, 'canDeactivateWarning', 'get');
+ let canDeactivate;
component['isLoading'] = false;
- component['unloadNotification']({ returnValue: '' });
- expect(deactivateWarningSpy).not.toHaveBeenCalled();
+ canDeactivate = component.canDeactivate();
+ expect(canDeactivate).toBeTrue();
component['isLoading'] = true;
- component['unloadNotification']({ returnValue: '' });
- expect(deactivateWarningSpy).toHaveBeenCalled();
+ canDeactivate = component.canDeactivate();
+ expect(canDeactivate).toBeFalse();
});
});
diff --git a/src/test/javascript/spec/component/standardized-competencies/course-import-standardized-prerequisites.spec.ts b/src/test/javascript/spec/component/standardized-competencies/course-import-standardized-prerequisites.spec.ts
index d6a319b34c84..78e5c6e90306 100644
--- a/src/test/javascript/spec/component/standardized-competencies/course-import-standardized-prerequisites.spec.ts
+++ b/src/test/javascript/spec/component/standardized-competencies/course-import-standardized-prerequisites.spec.ts
@@ -205,14 +205,14 @@ describe('CourseImportStandardizedPrerequisitesComponent', () => {
});
it('should not deactivate with pending changes', () => {
- const deactivateWarningSpy = jest.spyOn(component as any, 'canDeactivateWarning', 'get');
+ let canDeactivate;
component['isLoading'] = false;
- component['unloadNotification']({ returnValue: '' });
- expect(deactivateWarningSpy).not.toHaveBeenCalled();
+ canDeactivate = component.canDeactivate();
+ expect(canDeactivate).toBeTrue();
component['isLoading'] = true;
- component['unloadNotification']({ returnValue: '' });
- expect(deactivateWarningSpy).toHaveBeenCalled();
+ canDeactivate = component.canDeactivate();
+ expect(canDeactivate).toBeFalse();
});
});
diff --git a/src/test/javascript/spec/component/standardized-competencies/standardized-competency-management.spec.ts b/src/test/javascript/spec/component/standardized-competencies/standardized-competency-management.spec.ts
index 3e46a8a4add1..9c2b7f487793 100644
--- a/src/test/javascript/spec/component/standardized-competencies/standardized-competency-management.spec.ts
+++ b/src/test/javascript/spec/component/standardized-competencies/standardized-competency-management.spec.ts
@@ -479,15 +479,15 @@ describe('StandardizedCompetencyManagementComponent', () => {
});
it('should not deactivate with pending changes', () => {
- const deactivateWarningSpy = jest.spyOn(component, 'canDeactivateWarning', 'get');
+ let canDeactivate;
component['isEditing'] = false;
- component['unloadNotification']({ returnValue: '' });
- expect(deactivateWarningSpy).not.toHaveBeenCalled();
+ canDeactivate = component.canDeactivate();
+ expect(canDeactivate).toBeTrue();
component['isEditing'] = true;
- component['unloadNotification']({ returnValue: '' });
- expect(deactivateWarningSpy).toHaveBeenCalled();
+ canDeactivate = component.canDeactivate();
+ expect(canDeactivate).toBeFalse();
});
function prepareAndExecuteCompetencyUpdate(tree: KnowledgeAreaDTO[], competencyToUpdate: StandardizedCompetencyDTO, updatedCompetency: StandardizedCompetencyDTO) {
diff --git a/src/test/javascript/spec/helpers/sample/problemStatement.json b/src/test/javascript/spec/helpers/sample/problemStatement.json
index 125a7c25d5c9..6a9297f35013 100644
--- a/src/test/javascript/spec/helpers/sample/problemStatement.json
+++ b/src/test/javascript/spec/helpers/sample/problemStatement.json
@@ -7,8 +7,8 @@
"problemStatementBothFailedRendered": "\n- Implement Bubble Sort: artemisApp.editor.testStatusLabels.noResult
\nImplement the method performSort(List<Date>)
in the class BubbleSort
. Make sure to follow the Bubble Sort algorithm exactly. \n- Implement Merge Sort: artemisApp.editor.testStatusLabels.noResult
\nImplement the method performSort(List<Date>)
in the class MergeSort
. Make sure to follow the Merge Sort algorithm exactly. \n
\n",
"problemStatementBothFailedHtml": "\n- Implement Bubble Sort: artemisApp.editor.testStatusLabels.testFailing
\nImplement the method performSort(List<Date>)
in the class BubbleSort
. Make sure to follow the Bubble Sort algorithm exactly. \n- Implement Merge Sort: artemisApp.editor.testStatusLabels.testPassing
\nImplement the method performSort(List<Date>)
in the class MergeSort
. Make sure to follow the Merge Sort algorithm exactly. \n
\n",
"problemStatementBubbleSortFailsRendered": "\n- Implement Bubble Sort: artemisApp.editor.testStatusLabels.noResult
\nImplement the method performSort(List<Date>)
in the class BubbleSort
. Make sure to follow the Bubble Sort algorithm exactly. \n- Implement Merge Sort: artemisApp.editor.testStatusLabels.noResult
\nImplement the method performSort(List<Date>)
in the class MergeSort
. Make sure to follow the Merge Sort algorithm exactly. \n
\n",
- "problemStatementBubbleSortNotExecutedHtml": "\nImplement Bubble SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":0}] \nImplement the method performSort(List<Date>)
in the class BubbleSort
. Make sure to follow the Bubble Sort algorithm exactly. \nImplement Merge SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":1}] \nImplement the method performSort(List<Date>)
in the class MergeSort
. Make sure to follow the Merge Sort algorithm exactly. \n
",
+ "problemStatementBubbleSortNotExecutedHtml": "\nImplement Bubble SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":0}] \nImplement the method performSort(List<Date>)
in the class BubbleSort
. Make sure to follow the Bubble Sort algorithm exactly. \nImplement Merge SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":1}] \nImplement the method performSort(List<Date>)
in the class MergeSort
. Make sure to follow the Merge Sort algorithm exactly. \n
",
"problemStatementEmptySecondTask": "1. [task][Bubble Sort](1) \n Implement the method. \n 2. [task][Merge Sort]() \n Implement the method.",
- "problemStatementEmptySecondTaskNotExecutedHtml": "\nBubble SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":1}] \nImplement the method. \nMerge SortartemisApp.editor.testStatusLabels.noTests \nImplement the method. \n
",
+ "problemStatementEmptySecondTaskNotExecutedHtml": "\nBubble SortartemisApp.editor.testStatusLabels.totalTestsPassing: [{\"totalTests\":1,\"passedTests\":1}] \nImplement the method. \nMerge SortartemisApp.editor.testStatusLabels.noTests \nImplement the method. \n
",
"problemStatementPlantUMLWithTest": "@startuml\nclass Policy {\n1)>+configure()\n2)>+testWithParenthesis()}\n@enduml"
}
diff --git a/src/test/javascript/spec/service/markdown.service.spec.ts b/src/test/javascript/spec/service/markdown.service.spec.ts
index 6473a2b6405d..d33f0113eea9 100644
--- a/src/test/javascript/spec/service/markdown.service.spec.ts
+++ b/src/test/javascript/spec/service/markdown.service.spec.ts
@@ -108,4 +108,32 @@ describe('Markdown Service', () => {
const safeMarkdownWithoutExtras = htmlForMarkdown(markdownString, [], [], []);
expect(safeMarkdownWithoutExtras).toBe('Will this render blue?');
});
+
+ describe('formulaCompatibilityPlugin', () => {
+ it.each(['This is a formula $$E=mc^2$$ in text.', '$$a_1$$ formula at front', 'formula at back $$a_2$$'])('converts block formulas to inline formulas', (input) => {
+ const result = htmlForMarkdown(input);
+ expect(result).toContain('');
+ expect(result).not.toContain('class="katex-block"');
+ });
+
+ it('does not convert block formulas without surrounding text', () => {
+ const result = htmlForMarkdown('$$E=mc^2$$');
+ expect(result).toContain('class="katex-block"');
+ expect(result).toContain('display="block"');
+ });
+
+ it('converts double-backslash LaTeX begin and end tags', () => {
+ const result = htmlForMarkdown('Here is some LaTeX: $$\\\\begin{equation}a^2 + b^2 = c^2\\\\end{equation}$$\n');
+ expect(result).toContain('');
+ expect(result).toContain('class="katex-html"');
+ });
+
+ it('handles multiple formulas in the same text', () => {
+ const result = htmlForMarkdown('First formula $$a^2 + b^2 = c^2$$ and second formula $$E=mc^2$$.');
+ const formulaCount = (result.match(/class="katex"/g) || []).length;
+ expect(formulaCount).toBe(2);
+ expect(result).not.toContain('class="katex-block"');
+ expect(result).not.toContain('display="block"');
+ });
+ });
});