Skip to content

Commit 64184ac

Browse files
committed
fix: dark mode, navigation, quiz removal, exam multi-answers
1 parent 93070dd commit 64184ac

6 files changed

Lines changed: 428 additions & 70 deletions

File tree

frontend/src/app/pages/exam/exam.html

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,20 +61,33 @@ <h1>Simulateur d'Examen</h1>
6161
<div class="qhead">
6262
<span class="badge font-mono muted">{{ index + 1 }}/{{ questions.length }}</span>
6363
<span class="pill">{{ (q.domainKey ?? 'unknown') === 'development' ? 'Développement' : (q.domainKey ?? 'unknown') === 'security' ? 'Security' : (q.domainKey ?? 'unknown') === 'deployment' ? 'Deployment' : (q.domainKey ?? 'unknown') === 'troubleshooting' ? 'Troubleshooting & Optimization' : 'Unknown' }}</span>
64+
<span *ngIf="isMultiChoice(q)" class="pill multi-pill">Multi ({{ q.requiredAnswers }})</span>
6465
<button class="flag" (click)="toggleFlag()" [attr.aria-label]="isFlagged(q) ? 'Retirer le marqueur de la question ' + (index + 1) : 'Marquer la question ' + (index + 1) + ' pour revision'" [attr.aria-pressed]="isFlagged(q)">{{ isFlagged(q) ? '🚩' : '⚑' }}</button>
6566
</div>
6667

6768
<pre class="stem" [appGlossaryHandler]="q.stem" [innerHTML]="q.stem | glossary"></pre>
6869

70+
<!-- Message pour questions multi-réponses -->
71+
<div *ngIf="isMultiChoice(q)" class="multi-hint muted">
72+
Sélectionnez {{ q.requiredAnswers }} réponses
73+
<span *ngIf="chosenSetFor(q) as selected">
74+
({{ selected.size }}/{{ q.requiredAnswers }})
75+
</span>
76+
</div>
77+
6978
<div class="choices">
7079
<button
7180
class="choice"
7281
*ngFor="let k of choiceKeys(q)"
7382
(click)="pick(k)"
74-
[disabled]="submittingAttempt || answers.has(q.id)"
75-
[class.selected]="chosenFor(q) === k"
83+
[disabled]="submittingAttempt"
84+
[class.selected]="isChosen(q, k)"
85+
[class.multi-choice]="isMultiChoice(q)"
7686
[appGlossaryHandler]="q.choices[k]"
7787
>
88+
<div class="choice-indicator" [class.checkbox]="isMultiChoice(q)" [class.radio]="!isMultiChoice(q)" [class.checked]="isChosen(q, k)">
89+
<span *ngIf="isChosen(q, k)"></span>
90+
</div>
7891
<div class="choice-k">{{ k }}</div>
7992
<div class="choice-body" [innerHTML]="q.choices[k] | glossary"></div>
8093
</button>

frontend/src/app/pages/exam/exam.scss

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,20 @@ h1 {
187187
border: 1px solid hsl(var(--exam-border));
188188
}
189189

190+
.multi-hint {
191+
margin: 10px 0;
192+
padding: 8px 12px;
193+
background: hsl(var(--primary) / 0.1);
194+
border-radius: 8px;
195+
font-size: 14px;
196+
font-weight: 500;
197+
}
198+
199+
.multi-pill {
200+
background: hsl(200 90% 50% / 0.2) !important;
201+
color: hsl(200 90% 65%) !important;
202+
}
203+
190204
.choices {
191205
display: grid;
192206
gap: 10px;
@@ -203,12 +217,45 @@ h1 {
203217
display: flex;
204218
gap: 12px;
205219
align-items: center;
220+
cursor: pointer;
221+
transition: all 0.2s ease;
206222
}
207223

208-
.choice:hover {
224+
.choice:hover:not(:disabled) {
209225
border-color: hsl(var(--primary) / 0.4);
210226
}
211227

228+
.choice:disabled {
229+
opacity: 0.7;
230+
cursor: not-allowed;
231+
}
232+
233+
.choice-indicator {
234+
width: 22px;
235+
height: 22px;
236+
border: 2px solid hsl(var(--exam-border));
237+
display: flex;
238+
align-items: center;
239+
justify-content: center;
240+
flex-shrink: 0;
241+
transition: all 0.2s ease;
242+
}
243+
244+
.choice-indicator.checkbox {
245+
border-radius: 6px;
246+
}
247+
248+
.choice-indicator.radio {
249+
border-radius: 50%;
250+
}
251+
252+
.choice-indicator.checked {
253+
background: hsl(var(--primary));
254+
border-color: hsl(var(--primary));
255+
color: white;
256+
font-size: 14px;
257+
}
258+
212259
.choice-k {
213260
width: 30px;
214261
height: 30px;
@@ -223,6 +270,7 @@ h1 {
223270

224271
.choice.selected {
225272
outline: 2px solid hsl(var(--primary) / 0.35);
273+
border-color: hsl(var(--primary) / 0.5);
226274
}
227275

228276
.nav {

frontend/src/app/pages/exam/exam.spec.ts

Lines changed: 202 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ describe('Exam', () => {
2424
stem: 'What is AWS Lambda?',
2525
choices: { A: 'Compute', B: 'Storage' },
2626
answer: 'A',
27+
requiredAnswers: 1,
2728
conceptKey: 'lambda',
2829
domainKey: 'development',
2930
frExplanation: 'Explication',
@@ -38,6 +39,7 @@ describe('Exam', () => {
3839
stem: 'What is S3?',
3940
choices: { A: 'Compute', B: 'Storage' },
4041
answer: 'B',
42+
requiredAnswers: 1,
4143
conceptKey: 's3',
4244
domainKey: 'security',
4345
frExplanation: 'Explication',
@@ -52,6 +54,7 @@ describe('Exam', () => {
5254
stem: 'What is EC2?',
5355
choices: { A: 'Compute', B: 'Storage' },
5456
answer: 'A',
57+
requiredAnswers: 1,
5558
conceptKey: 'ec2',
5659
domainKey: 'deployment',
5760
frExplanation: 'Explication',
@@ -347,7 +350,7 @@ describe('Exam', () => {
347350
await fixture.whenStable();
348351
});
349352

350-
it('should not allow answering same question twice', async () => {
353+
it('should allow changing answer', async () => {
351354
component.startExam();
352355
httpMock.expectOne(`${baseUrl}/api/exams/start`).flush({
353356
mode: 'exam',
@@ -357,7 +360,10 @@ describe('Exam', () => {
357360
});
358361
await fixture.whenStable();
359362

363+
// Première réponse
360364
component.pick('A');
365+
expect(component.answers.get('q1')).toBe('A');
366+
361367
httpMock.expectOne(`${baseUrl}/api/attempts`).flush({
362368
ok: true,
363369
attemptId: 'att1',
@@ -366,8 +372,9 @@ describe('Exam', () => {
366372
});
367373
await fixture.whenStable();
368374

375+
// Changement de réponse
369376
component.pick('B');
370-
expect(component.answers.get('q1')).toBe('A');
377+
expect(component.answers.get('q1')).toBe('B');
371378
});
372379

373380
it('should return chosen answer for question', () => {
@@ -618,4 +625,197 @@ describe('Exam', () => {
618625
it('should not show negative time', () => {
619626
expect(component.formatTime(-10)).toBe('00:00:00');
620627
});
628+
629+
// Tests pour les questions multi-réponses
630+
describe('Multi-choice questions', () => {
631+
const multiQuestion: Question = {
632+
id: 'q-multi',
633+
exam: 'DVA-C02',
634+
topic: 1,
635+
questionNumber: 1,
636+
stem: 'Select two options:',
637+
choices: { A: 'Option A', B: 'Option B', C: 'Option C', D: 'Option D' },
638+
answer: 'A,B',
639+
requiredAnswers: 2,
640+
conceptKey: 'multi',
641+
domainKey: 'development',
642+
frExplanation: 'Explication',
643+
sourceUrl: 'url',
644+
textHash: 'hash-multi',
645+
};
646+
647+
it('should detect multi-choice question', () => {
648+
expect(component.isMultiChoice(multiQuestion)).toBe(true);
649+
expect(component.isMultiChoice(mockQuestions[0])).toBe(false);
650+
});
651+
652+
it('should allow selecting multiple answers up to requiredAnswers', async () => {
653+
component.startExam();
654+
httpMock.expectOne(`${baseUrl}/api/exams/start`).flush({
655+
mode: 'exam',
656+
count: 65,
657+
durationMinutes: 130,
658+
items: [multiQuestion],
659+
});
660+
await fixture.whenStable();
661+
662+
component.pick('A');
663+
httpMock.expectOne(`${baseUrl}/api/attempts`).flush({ ok: true, attemptId: 'att1', isCorrect: false, correctAnswer: 'A,B' });
664+
await fixture.whenStable();
665+
666+
component.pick('B');
667+
httpMock.expectOne(`${baseUrl}/api/attempts`).flush({ ok: true, attemptId: 'att2', isCorrect: false, correctAnswer: 'A,B' });
668+
await fixture.whenStable();
669+
670+
const answers = component.answers.get('q-multi') as Set<string>;
671+
expect(answers.size).toBe(2);
672+
expect(answers.has('A')).toBe(true);
673+
expect(answers.has('B')).toBe(true);
674+
});
675+
676+
it('should not allow more than requiredAnswers selections', async () => {
677+
component.startExam();
678+
httpMock.expectOne(`${baseUrl}/api/exams/start`).flush({
679+
mode: 'exam',
680+
count: 65,
681+
durationMinutes: 130,
682+
items: [multiQuestion],
683+
});
684+
await fixture.whenStable();
685+
686+
component.pick('A');
687+
httpMock.expectOne(`${baseUrl}/api/attempts`).flush({ ok: true, attemptId: 'att1', isCorrect: false, correctAnswer: 'A,B' });
688+
await fixture.whenStable();
689+
690+
component.pick('B');
691+
httpMock.expectOne(`${baseUrl}/api/attempts`).flush({ ok: true, attemptId: 'att2', isCorrect: false, correctAnswer: 'A,B' });
692+
await fixture.whenStable();
693+
694+
component.pick('C'); // Ne devrait pas être ajouté (déjà 2 réponses)
695+
696+
const answers = component.answers.get('q-multi') as Set<string>;
697+
expect(answers.size).toBe(2);
698+
expect(answers.has('C')).toBe(false);
699+
});
700+
701+
it('should allow deselecting answers', async () => {
702+
component.startExam();
703+
httpMock.expectOne(`${baseUrl}/api/exams/start`).flush({
704+
mode: 'exam',
705+
count: 65,
706+
durationMinutes: 130,
707+
items: [multiQuestion],
708+
});
709+
await fixture.whenStable();
710+
711+
component.pick('A');
712+
httpMock.expectOne(`${baseUrl}/api/attempts`).flush({ ok: true, attemptId: 'att1', isCorrect: false, correctAnswer: 'A,B' });
713+
await fixture.whenStable();
714+
715+
component.pick('B');
716+
httpMock.expectOne(`${baseUrl}/api/attempts`).flush({ ok: true, attemptId: 'att2', isCorrect: false, correctAnswer: 'A,B' });
717+
await fixture.whenStable();
718+
719+
component.pick('A'); // Désélectionner A
720+
721+
const answers = component.answers.get('q-multi') as Set<string>;
722+
expect(answers.size).toBe(1);
723+
expect(answers.has('A')).toBe(false);
724+
expect(answers.has('B')).toBe(true);
725+
});
726+
727+
it('should check if choice is selected for multi question', async () => {
728+
component.startExam();
729+
httpMock.expectOne(`${baseUrl}/api/exams/start`).flush({
730+
mode: 'exam',
731+
count: 65,
732+
durationMinutes: 130,
733+
items: [multiQuestion],
734+
});
735+
await fixture.whenStable();
736+
737+
expect(component.isChosen(multiQuestion, 'A')).toBe(false);
738+
739+
component.pick('A');
740+
httpMock.expectOne(`${baseUrl}/api/attempts`).flush({ ok: true, attemptId: 'att1', isCorrect: false, correctAnswer: 'A,B' });
741+
await fixture.whenStable();
742+
743+
expect(component.isChosen(multiQuestion, 'A')).toBe(true);
744+
expect(component.isChosen(multiQuestion, 'B')).toBe(false);
745+
});
746+
747+
it('should return chosen set for multi question', async () => {
748+
component.startExam();
749+
httpMock.expectOne(`${baseUrl}/api/exams/start`).flush({
750+
mode: 'exam',
751+
count: 65,
752+
durationMinutes: 130,
753+
items: [multiQuestion],
754+
});
755+
await fixture.whenStable();
756+
757+
expect(component.chosenSetFor(multiQuestion)).toBeNull();
758+
759+
component.pick('A');
760+
httpMock.expectOne(`${baseUrl}/api/attempts`).flush({ ok: true, attemptId: 'att1', isCorrect: false, correctAnswer: 'A,B' });
761+
await fixture.whenStable();
762+
763+
const set = component.chosenSetFor(multiQuestion);
764+
expect(set).not.toBeNull();
765+
expect(set!.has('A')).toBe(true);
766+
});
767+
768+
it('should calculate correct score for multi answers', async () => {
769+
component.startExam();
770+
httpMock.expectOne(`${baseUrl}/api/exams/start`).flush({
771+
mode: 'exam',
772+
count: 65,
773+
durationMinutes: 130,
774+
items: [multiQuestion],
775+
});
776+
await fixture.whenStable();
777+
778+
// Réponses correctes
779+
component.answers.set('q-multi', new Set(['A', 'B']));
780+
781+
const score = component.score();
782+
expect(score.correct).toBe(1);
783+
expect(score.total).toBe(1);
784+
expect(score.percent).toBe(100);
785+
});
786+
787+
it('should calculate incorrect score for partial multi answers', async () => {
788+
component.startExam();
789+
httpMock.expectOne(`${baseUrl}/api/exams/start`).flush({
790+
mode: 'exam',
791+
count: 65,
792+
durationMinutes: 130,
793+
items: [multiQuestion],
794+
});
795+
await fixture.whenStable();
796+
797+
// Réponses partielles (seulement A, pas B)
798+
component.answers.set('q-multi', new Set(['A', 'C']));
799+
800+
const score = component.score();
801+
expect(score.correct).toBe(0);
802+
expect(score.total).toBe(1);
803+
expect(score.percent).toBe(0);
804+
});
805+
806+
it('should return joined string for multi chosenFor', async () => {
807+
component.startExam();
808+
httpMock.expectOne(`${baseUrl}/api/exams/start`).flush({
809+
mode: 'exam',
810+
count: 65,
811+
durationMinutes: 130,
812+
items: [multiQuestion],
813+
});
814+
await fixture.whenStable();
815+
816+
component.answers.set('q-multi', new Set(['B', 'A']));
817+
818+
expect(component.chosenFor(multiQuestion)).toBe('A,B');
819+
});
820+
});
621821
});

0 commit comments

Comments
 (0)