Skip to content

Commit bb6e334

Browse files
committed
feat(host): add HostQuestionAnswers with per-type rendering
Renders answer section for each question type: - multiple_choice: option list with correct answer highlighted - true_false: True/False pair with correct one highlighted - open_ended: expected answer as plain text block
1 parent c2e2654 commit bb6e334

2 files changed

Lines changed: 201 additions & 0 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Question } from '@/db'
2+
3+
function MultipleChoice({ options, answer }: { options: string[]; answer: string }) {
4+
return (
5+
<ol className="flex flex-col gap-2">
6+
{options.map((option, i) => {
7+
const isCorrect = option === answer
8+
return (
9+
<li
10+
key={i}
11+
className="flex items-center gap-3 px-4 py-2.5 rounded-lg border text-sm font-medium"
12+
style={{
13+
borderColor: isCorrect ? '#16a34a' : 'var(--color-border)',
14+
background: isCorrect ? '#f0fdf4' : 'var(--color-surface)',
15+
color: isCorrect ? '#15803d' : 'var(--color-ink)',
16+
outline: isCorrect ? '2px solid #16a34a' : 'none',
17+
outlineOffset: '-2px',
18+
}}
19+
>
20+
<span
21+
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shrink-0"
22+
style={{
23+
background: isCorrect ? '#16a34a' : 'var(--color-border)',
24+
color: isCorrect ? '#fff' : 'var(--color-muted)',
25+
}}
26+
>
27+
{String.fromCharCode(65 + i)}
28+
</span>
29+
{option}
30+
{isCorrect && (
31+
<span className="ml-auto text-xs font-semibold" style={{ color: '#16a34a' }}>
32+
✓ Correct
33+
</span>
34+
)}
35+
</li>
36+
)
37+
})}
38+
</ol>
39+
)
40+
}
41+
42+
function TrueFalse({ answer }: { answer: string }) {
43+
return (
44+
<div className="flex gap-3">
45+
{(['True', 'False'] as const).map(option => {
46+
const isCorrect = option === answer
47+
return (
48+
<div
49+
key={option}
50+
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-lg border text-sm font-bold"
51+
style={{
52+
borderColor: isCorrect ? '#16a34a' : 'var(--color-border)',
53+
background: isCorrect ? '#f0fdf4' : 'var(--color-surface)',
54+
color: isCorrect ? '#15803d' : 'var(--color-muted)',
55+
outline: isCorrect ? '2px solid #16a34a' : 'none',
56+
outlineOffset: '-2px',
57+
}}
58+
>
59+
{isCorrect && <span></span>}
60+
{option}
61+
</div>
62+
)
63+
})}
64+
</div>
65+
)
66+
}
67+
68+
function OpenEnded({ answer }: { answer: string }) {
69+
return (
70+
<div
71+
className="px-4 py-3 rounded-lg border text-sm"
72+
style={{
73+
borderColor: 'var(--color-border)',
74+
background: 'var(--color-surface)',
75+
color: 'var(--color-ink)',
76+
}}
77+
>
78+
<p
79+
className="text-xs font-semibold uppercase tracking-wide mb-1"
80+
style={{ color: 'var(--color-muted)' }}
81+
>
82+
Expected answer
83+
</p>
84+
<p className="font-medium">{answer}</p>
85+
</div>
86+
)
87+
}
88+
89+
interface HostQuestionAnswersProps {
90+
question: Question
91+
}
92+
93+
export function HostQuestionAnswers({ question }: HostQuestionAnswersProps) {
94+
const { type, options, answer } = question
95+
return (
96+
<div className="flex flex-col gap-2">
97+
<p
98+
className="text-xs font-semibold uppercase tracking-wide"
99+
style={{ color: 'var(--color-muted)' }}
100+
>
101+
Answers
102+
</p>
103+
{type === 'multiple_choice' && <MultipleChoice options={options} answer={answer} />}
104+
{type === 'true_false' && <TrueFalse answer={answer} />}
105+
{type === 'open_ended' && <OpenEnded answer={answer} />}
106+
</div>
107+
)
108+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { render, screen } from '@testing-library/react'
3+
import { HostQuestionAnswers } from '@/components/host/HostQuestionAnswers'
4+
import type { Question } from '@/db'
5+
6+
const BASE: Question = {
7+
id: 'q1',
8+
title: 'Q',
9+
type: 'multiple_choice',
10+
options: ['Paris', 'Lyon', 'Marseille', 'Nice'],
11+
answer: 'Paris',
12+
description: '',
13+
difficulty: null,
14+
tags: [],
15+
media: null,
16+
mediaType: null,
17+
createdAt: 0,
18+
updatedAt: 0,
19+
}
20+
function q(o: Partial<Question>): Question {
21+
return { ...BASE, ...o }
22+
}
23+
24+
describe('HostQuestionAnswers — multiple_choice', () => {
25+
it('renders all four options', () => {
26+
render(<HostQuestionAnswers question={q({})} />)
27+
expect(screen.getByText('Paris')).toBeInTheDocument()
28+
expect(screen.getByText('Lyon')).toBeInTheDocument()
29+
expect(screen.getByText('Marseille')).toBeInTheDocument()
30+
expect(screen.getByText('Nice')).toBeInTheDocument()
31+
})
32+
it('highlights the correct option with green background', () => {
33+
render(<HostQuestionAnswers question={q({})} />)
34+
expect(screen.getByText('Paris').closest('li')).toHaveStyle({ background: '#f0fdf4' })
35+
})
36+
it('does not highlight incorrect options', () => {
37+
render(<HostQuestionAnswers question={q({})} />)
38+
expect(screen.getByText('Lyon').closest('li')).not.toHaveStyle({ background: '#f0fdf4' })
39+
})
40+
it('shows "✓ Correct" marker only on the correct option', () => {
41+
render(<HostQuestionAnswers question={q({})} />)
42+
expect(screen.getAllByText('✓ Correct')).toHaveLength(1)
43+
})
44+
it('renders alphabetic labels A–D', () => {
45+
render(<HostQuestionAnswers question={q({})} />)
46+
expect(screen.getByText('A')).toBeInTheDocument()
47+
expect(screen.getByText('B')).toBeInTheDocument()
48+
expect(screen.getByText('C')).toBeInTheDocument()
49+
expect(screen.getByText('D')).toBeInTheDocument()
50+
})
51+
})
52+
53+
describe('HostQuestionAnswers — true_false', () => {
54+
const tf = q({ type: 'true_false', options: ['True', 'False'], answer: 'True' })
55+
it('renders True and False options', () => {
56+
render(<HostQuestionAnswers question={tf} />)
57+
expect(screen.getByText('True')).toBeInTheDocument()
58+
expect(screen.getByText('False')).toBeInTheDocument()
59+
})
60+
it('highlights True when answer is True', () => {
61+
render(<HostQuestionAnswers question={tf} />)
62+
expect(screen.getByText('True').closest('div')).toHaveStyle({ background: '#f0fdf4' })
63+
expect(screen.getByText('False').closest('div')).not.toHaveStyle({ background: '#f0fdf4' })
64+
})
65+
it('highlights False when answer is False', () => {
66+
render(
67+
<HostQuestionAnswers
68+
question={q({ type: 'true_false', options: ['True', 'False'], answer: 'False' })}
69+
/>
70+
)
71+
expect(screen.getByText('False').closest('div')).toHaveStyle({ background: '#f0fdf4' })
72+
expect(screen.getByText('True').closest('div')).not.toHaveStyle({ background: '#f0fdf4' })
73+
})
74+
})
75+
76+
describe('HostQuestionAnswers — open_ended', () => {
77+
const oe = q({ type: 'open_ended', options: [], answer: 'The Eiffel Tower' })
78+
it('renders the expected answer text', () => {
79+
render(<HostQuestionAnswers question={oe} />)
80+
expect(screen.getByText('The Eiffel Tower')).toBeInTheDocument()
81+
})
82+
it('renders the "Expected answer" label', () => {
83+
render(<HostQuestionAnswers question={oe} />)
84+
expect(screen.getByText('Expected answer')).toBeInTheDocument()
85+
})
86+
})
87+
88+
describe('HostQuestionAnswers — section label', () => {
89+
it('renders "Answers" heading', () => {
90+
render(<HostQuestionAnswers question={q({})} />)
91+
expect(screen.getByText('Answers')).toBeInTheDocument()
92+
})
93+
})

0 commit comments

Comments
 (0)