Skip to content

Commit 0e2b90b

Browse files
FadyGergesRezktamang29kruscheFelixTJDietrich
authored
Quiz exercises: Fix apollon drag-and-drop quiz generation (#12559)
Co-authored-by: tamang29 <tamangravi01@gmail.com> Co-authored-by: Ravi Tamang <40029376+tamang29@users.noreply.github.com> Co-authored-by: Stephan Krusche <krusche@tum.de> Co-authored-by: Felix T.J. Dietrich <felix_dietrich@gmx.de>
1 parent 4f33aa7 commit 0e2b90b

10 files changed

Lines changed: 764 additions & 144 deletions

src/main/webapp/app/modeling/shared/apollon-model.util.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export interface ApollonModelData {
3232
// v3 format (legacy)
3333
elements?: Record<string, V3Element>;
3434
relationships?: Record<string, V3Element>;
35+
interactive?: {
36+
elements?: Record<string, boolean>;
37+
relationships?: Record<string, boolean>;
38+
};
3539
}
3640

3741
/**
@@ -115,6 +119,43 @@ export function getModelElementIds(model: UMLModel | ApollonModelData | undefine
115119
return new Set([...nodeIds, ...edgeIds]);
116120
}
117121

122+
export function hasExplicitInteractiveConfig(model: UMLModel | ApollonModelData | undefined): boolean {
123+
return !!(model as ApollonModelData | undefined)?.interactive;
124+
}
125+
126+
export function getExplicitInteractiveElementIds(model: UMLModel | ApollonModelData | undefined): string[] | undefined {
127+
if (!model) {
128+
return undefined;
129+
}
130+
131+
const interactive = (model as ApollonModelData).interactive;
132+
if (!interactive) {
133+
return undefined;
134+
}
135+
136+
const validIds = getModelElementIds(model);
137+
const elementIds = Object.entries(interactive.elements ?? {})
138+
.filter(([id, included]) => included && validIds.has(id))
139+
.map(([id]) => id);
140+
const relationshipIds = Object.entries(interactive.relationships ?? {})
141+
.filter(([id, included]) => included && validIds.has(id))
142+
.map(([id]) => id);
143+
144+
return [...elementIds, ...relationshipIds];
145+
}
146+
147+
export function getQuizRelevantElementIds(model: UMLModel | ApollonModelData | undefined): string[] {
148+
if (!model) {
149+
return [];
150+
}
151+
152+
if (hasExplicitInteractiveConfig(model)) {
153+
return getExplicitInteractiveElementIds(model) ?? [];
154+
}
155+
156+
return [...getModelElementIds(model)];
157+
}
158+
118159
/**
119160
* Checks if an Apollon model has elements (nodes).
120161
* This is the inverse of isModelEmpty for convenience.
@@ -125,3 +166,7 @@ export function getModelElementIds(model: UMLModel | ApollonModelData | undefine
125166
export function hasModelElements(model: UMLModel | ApollonModelData | undefined): boolean {
126167
return getModelNodes(model).length > 0;
127168
}
169+
170+
export function hasQuizRelevantElements(model: UMLModel | ApollonModelData | undefined): boolean {
171+
return getQuizRelevantElementIds(model).length > 0;
172+
}

src/main/webapp/app/quiz/manage/apollon-diagrams/detail/apollon-diagram-detail.component.spec.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,66 @@ import { provideHttpClientTesting } from '@angular/common/http/testing';
2424
import * as SVGRendererAPI from 'app/quiz/manage/apollon-diagrams/exercise-generation/svg-renderer';
2525
import { AUTOSAVE_EXERCISE_INTERVAL } from 'app/shared/constants/exercise-exam-constants';
2626

27+
function setupCanvasAndImageMocks() {
28+
const createMockCanvas = () => {
29+
const mockContext = {
30+
drawImage: vi.fn(),
31+
fillStyle: '',
32+
fillRect: vi.fn(),
33+
scale: vi.fn(),
34+
globalCompositeOperation: 'source-over',
35+
};
36+
37+
return {
38+
style: { width: '', height: '' },
39+
getContext: vi.fn().mockReturnValue(mockContext),
40+
toBlob: vi.fn((callback: (blob: Blob | null) => void) => callback(new Blob(['PNG'], { type: 'image/png' }))),
41+
width: 0,
42+
height: 0,
43+
} as unknown as HTMLCanvasElement;
44+
};
45+
46+
const originalCreateElement = document.createElement.bind(document);
47+
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName: string) => {
48+
if (tagName === 'canvas') {
49+
return createMockCanvas();
50+
}
51+
return originalCreateElement(tagName);
52+
});
53+
54+
const originalImage = globalThis.Image;
55+
class MockImage {
56+
width = 100;
57+
height = 100;
58+
private _src = '';
59+
onload: (() => void) | null = null;
60+
onerror: ((error: Event | string) => void) | null = null;
61+
62+
get src() {
63+
return this._src;
64+
}
65+
66+
set src(value: string) {
67+
this._src = value;
68+
setTimeout(() => this.onload?.(), 0);
69+
}
70+
}
71+
72+
vi.stubGlobal('Image', MockImage as any);
73+
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url');
74+
const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
75+
76+
return {
77+
cleanup: () => {
78+
createElementSpy.mockRestore();
79+
createObjectURLSpy.mockRestore();
80+
revokeObjectURLSpy.mockRestore();
81+
vi.unstubAllGlobals();
82+
globalThis.Image = originalImage;
83+
},
84+
};
85+
}
86+
2787
/**
2888
* RUTHLESS TEST SUITE: ApollonDiagramDetailComponent
2989
*
@@ -45,6 +105,7 @@ describe('ApollonDiagramDetail Component', () => {
45105
let courseService: CourseManagementService;
46106
let fixture: ComponentFixture<ApollonDiagramDetailComponent>;
47107
let alertService: AlertService;
108+
let cleanupCanvasAndImageMocks: (() => void) | undefined;
48109

49110
const course: Course = { id: 123 } as Course;
50111
const diagram: ApollonDiagram = new ApollonDiagram(UMLDiagramType.ClassDiagram, course.id!);
@@ -60,9 +121,6 @@ describe('ApollonDiagramDetail Component', () => {
60121
}),
61122
};
62123

63-
globalThis.URL.createObjectURL = vi.fn(() => 'blob:test-url');
64-
globalThis.URL.revokeObjectURL = vi.fn();
65-
66124
beforeEach(async () => {
67125
const route = {
68126
params: of({ id: 1, courseId: 123 }),
@@ -112,9 +170,12 @@ describe('ApollonDiagramDetail Component', () => {
112170
clip: { x: 0, y: 0, width: 100, height: 100 },
113171
});
114172
vi.spyOn(SVGRendererAPI, 'convertRenderedSVGToPNG').mockResolvedValue(new Blob(['PNG']));
173+
cleanupCanvasAndImageMocks = setupCanvasAndImageMocks().cleanup;
115174
});
116175

117176
afterEach(() => {
177+
cleanupCanvasAndImageMocks?.();
178+
cleanupCanvasAndImageMocks = undefined;
118179
vi.restoreAllMocks();
119180
vi.useRealTimers();
120181
});
@@ -201,7 +262,7 @@ describe('ApollonDiagramDetail Component', () => {
201262
...testClassDiagramV3,
202263
interactive: {
203264
elements: {},
204-
relationships: { 'rel-1': true },
265+
relationships: { '5a9a4eb3-8281-4de4-b0f2-3e2f164574bd': true },
205266
},
206267
} as unknown as UMLModel;
207268

src/main/webapp/app/quiz/manage/apollon-diagrams/detail/apollon-diagram-detail.component.ts

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ChangeDetectorRef, Component, ElementRef, NgZone, OnDestroy, OnInit, inject, input, output, signal, viewChild } from '@angular/core';
2-
import { ApollonEditor, ApollonMode, Locale, UMLModel } from '@tumaet/apollon';
2+
import { ApollonEditor, ApollonMode, ApollonView, Locale, UMLModel, importDiagram } from '@tumaet/apollon';
33
import { NgbModal, NgbModalRef, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
44
import { convertRenderedSVGToPNG } from '../exercise-generation/svg-renderer';
55
import { ApollonDiagramService } from 'app/quiz/manage/apollon-diagrams/services/apollon-diagram.service';
@@ -18,6 +18,7 @@ import { FormsModule, NgModel } from '@angular/forms';
1818
import { TranslateDirective } from 'app/shared/language/translate.directive';
1919
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
2020
import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe';
21+
import { hasQuizRelevantElements } from 'app/modeling/shared/apollon-model.util';
2122

2223
@Component({
2324
selector: 'jhi-apollon-diagram-detail',
@@ -48,6 +49,7 @@ export class ApollonDiagramDetailComponent implements OnInit, OnDestroy {
4849

4950
apollonDiagram = signal<ApollonDiagram | undefined>(undefined);
5051
apollonEditor?: ApollonEditor;
52+
private lastSavedModelJson = '';
5153

5254
isSaved = true;
5355

@@ -64,24 +66,7 @@ export class ApollonDiagramDetailComponent implements OnInit, OnDestroy {
6466
* v4 format: model.nodes/edges are arrays - in v4 ALL elements are considered interactive
6567
*/
6668
get hasInteractive(): boolean {
67-
if (!this.apollonEditor) {
68-
return false;
69-
}
70-
const model = this.apollonEditor.model as any;
71-
72-
// v3 format: check interactive.elements/relationships
73-
if (model.interactive) {
74-
const elements = model.interactive.elements ?? {};
75-
const relationships = model.interactive.relationships ?? {};
76-
return Object.values(elements).some(Boolean) || Object.values(relationships).some(Boolean);
77-
}
78-
79-
// v4 format: nodes and edges are ARRAYS - if there are any elements, they're interactive
80-
if (Array.isArray(model.nodes)) {
81-
return model.nodes.length > 0 || (model.edges?.length ?? 0) > 0;
82-
}
83-
84-
return false;
69+
return hasQuizRelevantElements(this.apollonEditor?.model);
8570
}
8671

8772
/** Whether some elements are selected in the apollon editor. */
@@ -114,7 +99,8 @@ export class ApollonDiagramDetailComponent implements OnInit, OnDestroy {
11499

115100
this.apollonDiagram.set(diagram);
116101

117-
const model: UMLModel = diagram.jsonRepresentation ? JSON.parse(diagram.jsonRepresentation) : undefined;
102+
const model: UMLModel | undefined = diagram.jsonRepresentation ? importDiagram(JSON.parse(diagram.jsonRepresentation)) : undefined;
103+
this.lastSavedModelJson = model ? JSON.stringify(model) : '';
118104
this.initializeApollonEditor(model);
119105
this.setAutoSaveTimer();
120106
},
@@ -141,26 +127,30 @@ export class ApollonDiagramDetailComponent implements OnInit, OnDestroy {
141127
* Initializes Apollon Editor with UML Model
142128
* @param initialModel
143129
*/
144-
initializeApollonEditor(initialModel: UMLModel) {
130+
initializeApollonEditor(initialModel?: UMLModel) {
145131
if (this.apollonEditor) {
146132
this.apollonEditor.destroy();
147133
}
148134

149135
const diagram = this.apollonDiagram();
150-
this.apollonEditor = new ApollonEditor(this.editorContainer().nativeElement, {
136+
const editorOptions = {
151137
mode: ApollonMode.Modelling,
138+
view: ApollonView.Modelling,
139+
readonly: false,
152140
model: initialModel,
153141
type: diagram?.diagramType,
154142
locale: this.translateService.getCurrentLang() as Locale,
155-
});
143+
availableViews: [ApollonView.Modelling, ApollonView.Highlight],
144+
} as ConstructorParameters<typeof ApollonEditor>[1];
145+
this.apollonEditor = new ApollonEditor(this.editorContainer().nativeElement, editorOptions);
156146
// Expose the ApollonEditor instance on the host DOM element for E2E test access.
157147
(this.elementRef.nativeElement as any).__apollonEditor = this.apollonEditor;
158148
// Wrap callback in NgZone.run() because Apollon's React/Zustand store fires outside Angular's zone.
159149
// Without this, programmatic model updates (e.g., from E2E tests) don't trigger change detection,
160150
// leaving template bindings like [disabled]="!hasInteractive" stale.
161151
this.apollonEditor.subscribeToModelChange((newModel) => {
162152
this.ngZone.run(() => {
163-
this.isSaved = JSON.stringify(newModel) === this.apollonDiagram()?.jsonRepresentation;
153+
this.isSaved = JSON.stringify(newModel) === this.lastSavedModelJson;
164154
this.changeDetectorRef.markForCheck();
165155
});
166156
});
@@ -181,6 +171,8 @@ export class ApollonDiagramDetailComponent implements OnInit, OnDestroy {
181171
const result = await lastValueFrom(this.apollonDiagramService.update(updatedDiagram, this.courseId()));
182172
if (result?.ok) {
183173
this.alertService.success('artemisApp.apollonDiagram.updated', { title: this.apollonDiagram()?.title });
174+
this.lastSavedModelJson = JSON.stringify(umlModel);
175+
this.apollonDiagram.set(updatedDiagram);
184176
this.isSaved = true;
185177
this.setAutoSaveTimer();
186178
return true;
@@ -273,6 +265,7 @@ export class ApollonDiagramDetailComponent implements OnInit, OnDestroy {
273265
const svg = await this.apollonEditor.exportAsSVG({
274266
keepOriginalSize: !this.crop,
275267
include: selection,
268+
svgMode: 'compat',
276269
});
277270
const png = await convertRenderedSVGToPNG(svg);
278271
this.download(png);

0 commit comments

Comments
 (0)