Skip to content

Commit 0fb0009

Browse files
committed
feat(frontend): add quiz component tests
1 parent d94f710 commit 0fb0009

12 files changed

Lines changed: 1247 additions & 24 deletions

frontend/app/components/challenges/QuizChallenge.vue

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -177,23 +177,27 @@ onMounted(() => {
177177
})
178178
179179
// Watch for needsToStartQuiz transitions (handles both initial mount and post-enrollment refresh)
180-
watch(needsToStartQuiz, async (needs) => {
181-
if (!needs || !props.challenge.quiz.userActiveSession?.id) return
182-
isLoading.value = true
183-
const result = await startQuizSession({
184-
sessionId: props.challenge.quiz.userActiveSession.id,
185-
})
186-
if (result.data?.startQuizSession) {
187-
startedSubmission.value = result.data.startQuizSession
188-
}
189-
isLoading.value = false
190-
emit('start')
191-
track(AnalyticsEvent.QuizStarted, {
192-
quiz_id: props.challenge.quiz.id,
193-
quiz_name: props.challenge.name,
194-
challenge_id: props.challenge.id,
195-
})
196-
}, { immediate: true })
180+
watch(
181+
needsToStartQuiz,
182+
async (needs) => {
183+
if (!needs || !props.challenge.quiz.userActiveSession?.id) return
184+
isLoading.value = true
185+
const result = await startQuizSession({
186+
sessionId: props.challenge.quiz.userActiveSession.id,
187+
})
188+
if (result.data?.startQuizSession) {
189+
startedSubmission.value = result.data.startQuizSession
190+
}
191+
isLoading.value = false
192+
emit('start')
193+
track(AnalyticsEvent.QuizStarted, {
194+
quiz_id: props.challenge.quiz.id,
195+
quiz_name: props.challenge.name,
196+
challenge_id: props.challenge.id,
197+
})
198+
},
199+
{ immediate: true },
200+
)
197201
198202
const activeSubmission = computed(() => {
199203
// Determine the target submission ID
@@ -453,6 +457,22 @@ const activeRef = computed(() =>
453457
const actionState = computed(() => activeRef.value?.actionState)
454458
const handlers = computed(() => activeRef.value?.handlers)
455459
460+
// When correct answers are hidden (e.g. the quiz is used as a survey), locking
461+
// an answer reveals nothing, so the separate "continue" tap is just friction.
462+
// Submit and advance in one step. Reveal-enabled quizzes keep the two-step flow
463+
// so users can see the correct/wrong result before continuing.
464+
async function onLockAnswer() {
465+
await handlers.value?.submit()
466+
// Only auto-advance if the answer actually locked — submit() no-ops on empty
467+
// input (e.g. blank free text), and we must not skip an unanswered question.
468+
if (
469+
props.challenge.quiz.revealCorrectAnswers === false &&
470+
actionState.value?.isAnswerLocked
471+
) {
472+
handlers.value?.continue()
473+
}
474+
}
475+
456476
const footerState = computed(() => {
457477
const state = actionState.value
458478
if (!state) return null
@@ -472,6 +492,14 @@ const continueButtonText = computed(() => {
472492
return t('quiz.nextQuestion')
473493
})
474494
495+
// When results are hidden the lock button just advances, so label it as such
496+
// rather than "Lock answer".
497+
const lockButtonText = computed(() =>
498+
props.challenge.quiz.revealCorrectAnswers === false
499+
? continueButtonText.value
500+
: t('quiz.lockAnswer'),
501+
)
502+
475503
// Determine button text for next action in review mode
476504
const nextButtonText = computed(() => {
477505
if (actionState.value?.isLastQuestion) {
@@ -839,9 +867,9 @@ const progressResults = computed(() => {
839867
size="large"
840868
:disabled="footerState.button.disabled"
841869
:loading="actionState?.isSubmitting"
842-
@click="handlers?.submit"
870+
@click="onLockAnswer"
843871
>
844-
{{ $t('quiz.lockAnswer') }}
872+
{{ lockButtonText }}
845873
</DesignButton>
846874

847875
<DesignButton

frontend/app/components/challenges/quiz/QuizResult.vue

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,6 @@ onMounted(() => {
9494
<h1 class="text-heading text-text-default">
9595
{{ $t('quiz.result.thanksForAnswers') }}
9696
</h1>
97-
<p class="text-body text-text-secondary">
98-
{{ $t('quiz.result.resultsRevealedLater') }}
99-
</p>
10097
</div>
10198

10299
<div class="flex flex-col gap-small">
@@ -130,7 +127,7 @@ onMounted(() => {
130127

131128
<h1 class="text-heading text-text-default tabular-nums">
132129
{{ resultText }}
133-
<br >
130+
<br />
134131
{{ pointsText }}
135132
</h1>
136133
</div>

frontend/app/components/challenges/quiz/questions/QuizNumberQuestion.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ const submittedResult = ref<{ isCorrect: boolean | null } | null>(
4646
)
4747
4848
async function handleLockAnswer() {
49-
if (!currentValue.value || isSubmitting.value) return
49+
// 0 is a valid answer (minValue can be 0), so guard on nullish, not falsy.
50+
if (currentValue.value == null || isSubmitting.value) return
5051
5152
isSubmitting.value = true
5253
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// @vitest-environment nuxt
2+
import { describe, it, expect } from 'vitest'
3+
import { mountSuspended } from '@nuxt/test-utils/runtime'
4+
import QuizAlternative from '~/components/challenges/quiz/QuizAlternative.vue'
5+
6+
type Props = InstanceType<typeof QuizAlternative>['$props']
7+
8+
// The test env renders the English (fallback) locale.
9+
const T = {
10+
yourAnswer: 'Your answer',
11+
wrongAnswer: 'Incorrect answer',
12+
correctAnswer: 'Correct answer',
13+
}
14+
15+
function mountAlt(props: Partial<Props> = {}) {
16+
return mountSuspended(QuizAlternative, {
17+
props: { text: 'Option A', ...props } as Props,
18+
})
19+
}
20+
21+
const hasIcon = (w: Awaited<ReturnType<typeof mountAlt>>, name: string) =>
22+
w.findComponent({ name }).exists()
23+
24+
describe('QuizAlternative', () => {
25+
it('renders the answer text', async () => {
26+
const wrapper = await mountAlt({ text: 'The capital is Oslo' })
27+
expect(wrapper.text()).toContain('The capital is Oslo')
28+
})
29+
30+
it('applies the disabled attribute', async () => {
31+
const wrapper = await mountAlt({ disabled: true })
32+
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
33+
})
34+
35+
it('shows no status badge before confirmation', async () => {
36+
const wrapper = await mountAlt({
37+
highlighted: true,
38+
selected: true,
39+
confirmed: false,
40+
})
41+
expect(hasIcon(wrapper, 'IconCheck')).toBe(false)
42+
expect(hasIcon(wrapper, 'IconClose')).toBe(false)
43+
expect(hasIcon(wrapper, 'IconLock')).toBe(false)
44+
})
45+
46+
it('marks a correct answer with a positive badge', async () => {
47+
const wrapper = await mountAlt({ confirmed: true, correct: true })
48+
expect(hasIcon(wrapper, 'IconCheck')).toBe(true)
49+
expect(wrapper.text()).toContain(T.correctAnswer)
50+
expect(wrapper.find('.bg-accent-positive').exists()).toBe(true)
51+
})
52+
53+
it('marks your own wrong answer as "your answer"', async () => {
54+
const wrapper = await mountAlt({
55+
confirmed: true,
56+
wrong: true,
57+
selected: true,
58+
})
59+
expect(hasIcon(wrapper, 'IconClose')).toBe(true)
60+
expect(wrapper.text()).toContain(T.yourAnswer)
61+
expect(wrapper.find('.bg-accent-negative').exists()).toBe(true)
62+
})
63+
64+
it('marks a non-selected wrong answer as "wrong answer"', async () => {
65+
const wrapper = await mountAlt({
66+
confirmed: true,
67+
wrong: true,
68+
selected: false,
69+
})
70+
expect(hasIcon(wrapper, 'IconClose')).toBe(true)
71+
expect(wrapper.text()).toContain(T.wrongAnswer)
72+
})
73+
74+
it('marks your selected answer as locked when correctness is hidden', async () => {
75+
const wrapper = await mountAlt({
76+
confirmed: true,
77+
selected: true,
78+
correct: false,
79+
wrong: false,
80+
})
81+
// Lock icon + "your answer", using the neutral accent (not positive/negative).
82+
expect(hasIcon(wrapper, 'IconLock')).toBe(true)
83+
expect(wrapper.text()).toContain(T.yourAnswer)
84+
expect(wrapper.find('.bg-accent-positive').exists()).toBe(false)
85+
expect(wrapper.find('.bg-accent-negative').exists()).toBe(false)
86+
})
87+
})
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// @vitest-environment nuxt
2+
import { describe, it, expect } from 'vitest'
3+
import { mountSuspended } from '@nuxt/test-utils/runtime'
4+
import QuizBettingModule from '~/components/challenges/quiz/QuizBettingModule.vue'
5+
import DesignSlider from '~/components/design/DesignSlider.vue'
6+
7+
type Props = InstanceType<typeof QuizBettingModule>['$props']
8+
9+
function mountBet(props: Partial<Props> = {}) {
10+
return mountSuspended(QuizBettingModule, {
11+
props: { availablePoints: 100, ...props } as Props,
12+
})
13+
}
14+
15+
type Wrapper = Awaited<ReturnType<typeof mountBet>>
16+
const slider = (w: Wrapper) => w.findComponent(DesignSlider)
17+
18+
describe('QuizBettingModule', () => {
19+
describe('betting mode', () => {
20+
it('shows the bet and remaining points', async () => {
21+
const wrapper = await mountBet({ availablePoints: 100, modelValue: 30 })
22+
expect(wrapper.text()).toContain('30') // your bet
23+
expect(wrapper.text()).toContain('70') // remaining
24+
})
25+
26+
it('defaults the slider bounds to 0..availablePoints', async () => {
27+
const wrapper = await mountBet({ availablePoints: 100 })
28+
expect(slider(wrapper).props('min')).toBe(0)
29+
expect(slider(wrapper).props('max')).toBe(100)
30+
})
31+
32+
it('applies percentage limits to the slider bounds', async () => {
33+
const wrapper = await mountBet({
34+
availablePoints: 100,
35+
minPercentage: 10,
36+
maxPercentage: 50,
37+
})
38+
expect(slider(wrapper).props('min')).toBe(10)
39+
expect(slider(wrapper).props('max')).toBe(50)
40+
})
41+
42+
it('takes the stricter of percentage and absolute limits', async () => {
43+
const wrapper = await mountBet({
44+
availablePoints: 100,
45+
minPercentage: 10, // -> 10
46+
minAbsolute: 20, // stricter min wins
47+
maxPercentage: 50, // -> 50
48+
maxAbsolute: 30, // stricter max wins
49+
})
50+
expect(slider(wrapper).props('min')).toBe(20)
51+
expect(slider(wrapper).props('max')).toBe(30)
52+
})
53+
54+
it('shows the registered-bet message instead of the slider when locked', async () => {
55+
const wrapper = await mountBet({ mode: 'locked' })
56+
expect(wrapper.text()).toContain('The bet has been registered')
57+
expect(slider(wrapper).exists()).toBe(false)
58+
})
59+
60+
it('shows the registered-bet message when disabled', async () => {
61+
const wrapper = await mountBet({ disabled: true })
62+
expect(wrapper.text()).toContain('The bet has been registered')
63+
expect(slider(wrapper).exists()).toBe(false)
64+
})
65+
})
66+
67+
describe('results mode', () => {
68+
it('shows a win with a positive points value and multiplier', async () => {
69+
const wrapper = await mountBet({
70+
mode: 'results',
71+
betAmount: 10,
72+
pointsEarned: 20, // resultAmount 30 -> x3
73+
correctCount: 2,
74+
totalCount: 3,
75+
})
76+
expect(wrapper.text()).toContain('+20')
77+
expect(wrapper.text()).toContain('Points earned')
78+
expect(wrapper.text()).toContain('x 3')
79+
})
80+
81+
it('renders a fractional multiplier to two decimals', async () => {
82+
const wrapper = await mountBet({
83+
mode: 'results',
84+
betAmount: 10,
85+
pointsEarned: 5, // resultAmount 15 -> x1.5
86+
correctCount: 1,
87+
totalCount: 3,
88+
})
89+
expect(wrapper.text()).toContain('x 1.5')
90+
})
91+
92+
it('shows a loss with the negative points value', async () => {
93+
const wrapper = await mountBet({
94+
mode: 'results',
95+
betAmount: 10,
96+
pointsEarned: -5,
97+
correctCount: 0,
98+
totalCount: 3,
99+
})
100+
expect(wrapper.text()).toContain('Points lost')
101+
expect(wrapper.text()).toContain('-5')
102+
})
103+
104+
it('hides the multiplier when there are no correct answers', async () => {
105+
const wrapper = await mountBet({
106+
mode: 'results',
107+
betAmount: 10,
108+
pointsEarned: -10,
109+
correctCount: 0,
110+
totalCount: 3,
111+
})
112+
// correctCount 0 -> "none" label, no multiplier row
113+
expect(wrapper.text()).toContain("You didn't have any correct ones.")
114+
expect(wrapper.text()).not.toContain('x ')
115+
})
116+
})
117+
})

0 commit comments

Comments
 (0)