elemes/services/tests/test_lesson_service_quiz.py

640 lines
20 KiB
Python

import pytest
pytestmark = pytest.mark.unit
from services.lesson_service import _parse_flashcards
def test_mcq_multiline_prompt_code_fence_and_paragraph():
md_text = """### Perhatikan program berikut
```c
int x = 2;
printf("%d", x * 3);
```
Berapakah keluarannya?
- [] 2
- [] 3
- [x] 6
- [] Error
> `x * 3` menghasilkan `6`.
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['type'] == 'mcq'
# Code fence dirender di question, bukan hilang
assert '<pre><code' in q['question']
assert 'language-c' in q['question']
# Paragraf tambahan ikut masuk question
assert 'Berapakah keluarannya?' in q['question']
# Opsi tidak bocor ke question
assert '- []' not in q['question']
assert len(q['options']) == 4
# Opsi dirender markdown (inline code)
assert q['options'][2]['text'].strip() == '<p>6</p>'
assert q['options'][2]['is_correct'] is True
def test_mcq_table_and_math_rendered_in_question():
md_text = """### Dari tabel berikut, berapa totalnya?
| A | B |
|---|---|
| 1 | 2 |
| 3 | 4 |
$$x^2 + y = 10$$
- [x] 10
- [] 9
- [] 11
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert '<table' in q['question']
assert 'x^2' in q['question']
assert len(q['options']) == 3
def test_mcq_image_directive_extracted_not_in_question():
md_text = """### Apa warna langit pada siang hari?
image: demo_quiz.png
- [x] Biru
- [] Hijau
- [] Merah
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['image'] == '/assets/demo_quiz.png'
assert 'image:' not in q['question']
assert 'demo_quiz.png' not in q['question']
def test_mcq_explanation_after_options_not_in_question_or_options():
md_text = """### Apa output dari `printf("%d", 5 + 5);`?
- [] 55
- [x] 10
- [] 5 + 5
> Penjelasan: Operasi `5 + 5` dihitung dulu menjadi `10`.
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['explanation'] != ''
assert 'Penjelasan' in q['explanation']
assert 'Penjelasan' not in q['question']
for opt in q['options']:
assert 'Penjelasan' not in opt['text']
def test_flashcard_simple_compat():
md_text = """### Apa fungsi dari `return 0;` di dalam fungsi `main()`?
Menandakan bahwa program telah selesai berjalan dengan sukses.
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['type'] == 'flashcard'
assert 'return' in q['front']
assert 'Menandakan bahwa program' in q['back']
assert q['options'] is None or q['options'] == []
def test_flashcard_with_explanation_back_clean():
md_text = """### Bagaimana cara menulis komentar satu baris di bahasa C?
Cukup gunakan dua garis miring di awal baris.
> Penjelasan: Komentar diabaikan oleh compiler.
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['type'] == 'flashcard'
assert 'Penjelasan' in q['explanation']
assert 'Penjelasan' not in q['back']
assert 'dua garis miring' in q['back']
def test_stable_ids_questions_and_options():
md_text = """### Soal pertama?
- [x] A
- [] B
### Soal kedua?
- [] C
- [x] D
"""
quiz = _parse_flashcards(md_text)
assert [q['id'] for q in quiz] == ['q-0', 'q-1']
assert [o['id'] for o in quiz[0]['options']] == ['q-0-o-0', 'q-0-o-1']
assert [o['id'] for o in quiz[1]['options']] == ['q-1-o-0', 'q-1-o-1']
def test_mcq_zero_correct_raises_valueerror():
md_text = """### Soal tanpa jawaban benar?
- [] A
- [] B
- [] C
"""
with pytest.raises(ValueError):
_parse_flashcards(md_text)
def test_mcq_two_correct_raises_valueerror():
md_text = """### Soal dengan dua jawaban benar?
- [x] A
- [] B
- [x] C
"""
with pytest.raises(ValueError):
_parse_flashcards(md_text)
def test_options_inside_code_fence_ignored():
md_text = """### Mana yang benar?
```markdown
- [x] ini bukan opsi, ini contoh di dalam code fence
- [] juga bukan
```
- [x] Opsi asli 1
- [] Opsi asli 2
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert len(q['options']) == 2
# Isi code fence tetap ada di question
assert 'ini bukan opsi' in q['question']
assert 'Opsi asli 1' not in q['question']
def test_image_markdown_in_question_heading():
md_text = """### Pilih gambar yang melambangkan bahasa C:
![C Logo](https://upload.wikimedia.org/wikipedia/commons/1/18/C_Programming_Language.svg)
- [x] Benar
- [] Salah
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['image'] == 'https://upload.wikimedia.org/wikipedia/commons/1/18/C_Programming_Language.svg'
def test_empty_and_blank_text_returns_empty_list():
assert _parse_flashcards("") == []
assert _parse_flashcards(" \n ") == []
def test_old_flat_format_still_parses_with_ids():
md_text = """### Bahasa C adalah bahasa tingkat rendah.
- [] Benar
- [x] Salah
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
assert quiz[0]['id'] == 'q-0'
assert quiz[0]['type'] == 'mcq'
assert len(quiz[0]['options']) == 2
assert quiz[0]['options'][1]['is_correct'] is True
def test_default_category_is_evaluasi():
md_text = """### Mana yang benar?
- [] Salah
- [x] Benar
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
assert quiz[0]['category'] == 'evaluasi'
def test_diagnostic_marker_on_own_line_sets_category_mcq():
md_text = """### Apa output dari `printf("%d", 5 + 5);`?
::diagnostic
- [] 55
- [x] 10
- [] 5 + 5
> Penjelasan: 5 + 5 = 10.
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['category'] == 'diagnostik'
# Marker tidak boleh bocor ke teks yang dirender
assert '::diagnostic' not in q['question']
assert len(q['options']) == 3
def test_diagnostic_marker_on_own_line_sets_category_flashcard():
md_text = """### Apa fungsi `return 0;`?
::diagnostic
Program selesai dengan sukses.
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['category'] == 'diagnostik'
assert '::diagnostic' not in q['front']
assert 'sukses' in q['back']
def test_diagnostic_marker_inline_in_heading_does_not_set_category():
"""Kontrak: marker wajib di baris sendiri — format inline (lama) diabaikan."""
md_text = """### Apa output dari `printf("%d", 5 + 5);` <-- ::diagnostic
- [] 55
- [x] 10
- [] 5 + 5
"""
quiz = _parse_flashcards(md_text)
assert len(quiz) == 1
q = quiz[0]
assert q['category'] == 'evaluasi'
# Konten heading tetap dipertahankan apa adanya (termasuk komentar)
assert '::diagnostic' in q['question']
def test_diagnostic_mixed_questions_categorized_independently():
md_text = """### Soal evaluasi 1
- [] a
- [x] b
### Soal diagnostik 1
::diagnostic
- [] a
- [x] b
### Soal evaluasi 2
- [] a
- [x] b
"""
quiz = _parse_flashcards(md_text)
assert [q['category'] for q in quiz] == ['evaluasi', 'diagnostik', 'evaluasi']
# ── Fitur B: taksonomi key quiz (QUIZ_MCQ / QUIZ_WEIGHTED / FLASHCARD) ──
class TestQuizMcqKey:
"""Key ---QUIZ_MCQ--- — MCQ biner, validasi ketat."""
def test_basic_parse(self):
md_text = """### Berapa 1+1?
- [] 3
- [x] 2
"""
quiz = _parse_flashcards(md_text, kind='mcq')
assert len(quiz) == 1
q = quiz[0]
assert q['type'] == 'mcq'
assert q['quiz_type'] == 'mcq'
assert q['id'] == 'q-0'
assert [o['weight'] for o in q['options']] == [0, 1]
assert q['options'][1]['is_correct'] is True
def test_weighted_syntax_rejected(self):
md_text = """### Soal MCQ dengan bobot?
- [10] A
- [ ] B
"""
with pytest.raises(ValueError, match="tidak boleh memakai bobot angka"):
_parse_flashcards(md_text, kind='mcq')
def test_two_correct_rejected(self):
md_text = """### Dua kunci?
- [x] A
- [x] B
"""
with pytest.raises(ValueError, match="tepat satu opsi benar"):
_parse_flashcards(md_text, kind='mcq')
def test_zero_correct_rejected(self):
md_text = """### Tanpa kunci?
- [] A
- [] B
"""
with pytest.raises(ValueError, match="tepat satu opsi benar"):
_parse_flashcards(md_text, kind='mcq')
def test_without_options_rejected(self):
"""QUIZ_MCQ tanpa opsi tidak valid — bukan flashcard."""
md_text = "### Pertanyaan tanpa opsi\njawaban bebas\n"
with pytest.raises(ValueError, match="wajib punya opsi jawaban"):
_parse_flashcards(md_text, kind='mcq')
def test_diagnostic_marker_still_works(self):
"""::diagnostic tetap kategori di dalam QUIZ_MCQ dan masuk skor resmi."""
md_text = """### Soal diagnostik
::diagnostic
- [x] a
- [] b
"""
quiz = _parse_flashcards(md_text, kind='mcq')
assert quiz[0]['category'] == 'diagnostik'
assert quiz[0]['quiz_type'] == 'mcq'
class TestQuizWeightedKey:
"""Key ---QUIZ_WEIGHTED--- — MCQ berbobot [N]."""
def test_basic_parse_max_weight_is_key(self):
md_text = """### Seberapa tepat pernyataan ini?
- [10] Paling tepat
- [7] Kurang tepat
- [ ] Salah
"""
quiz = _parse_flashcards(md_text, kind='weighted')
assert len(quiz) == 1
q = quiz[0]
assert q['type'] == 'mcq'
assert q['quiz_type'] == 'weighted'
assert [o['weight'] for o in q['options']] == [10, 7, 0]
# is_correct = opsi bobot tertinggi
assert [o['is_correct'] for o in q['options']] == [True, False, False]
def test_x_mark_rejected(self):
md_text = """### Campur [x] dengan bobot?
- [x] A
- [7] B
- [ ] C
"""
with pytest.raises(ValueError, match="tidak boleh memakai penanda"):
_parse_flashcards(md_text, kind='weighted')
def test_zero_weight_rejected(self):
md_text = """### Bobot nol?
- [10] A
- [0] B
- [ ] C
"""
with pytest.raises(ValueError, match="bobot harus berupa bilangan bulat >= 1"):
_parse_flashcards(md_text, kind='weighted')
def test_missing_blank_option_rejected(self):
"""Semua opsi berbobot — tidak ada [ ] sbg jawaban salah → error."""
md_text = """### Tanpa opsi kosong?
- [10] A
- [7] B
- [3] C
"""
with pytest.raises(ValueError, match="minimal satu opsi tanpa bobot"):
_parse_flashcards(md_text, kind='weighted')
def test_duplicate_max_weight_rejected(self):
md_text = """### Bobot tertinggi dobel?
- [10] A
- [10] B
- [ ] C
"""
with pytest.raises(ValueError, match="bobot tertinggi harus unik"):
_parse_flashcards(md_text, kind='weighted')
def test_without_options_rejected(self):
md_text = "### Pertanyaan tanpa opsi\njawaban bebas\n"
with pytest.raises(ValueError, match="wajib punya opsi jawaban"):
_parse_flashcards(md_text, kind='weighted')
def test_partial_credit_possible(self):
"""Opsi [7] bukan kunci tapi tetap membawa poin parsial."""
md_text = """### Penilaian bertingkat?
- [10] Sempurna
- [7] Cukup
- [ ] Salah
"""
q = _parse_flashcards(md_text, kind='weighted')[0]
weights = {o['text']: o['weight'] for o in q['options']}
assert weights['<p>Sempurna</p>'] == 10
assert weights['<p>Cukup</p>'] == 7
assert weights['<p>Salah</p>'] == 0
def test_explanation_still_parsed(self):
md_text = """### Soal berbobot?
- [10] A
- [ ] B
> Pembahasan soal berbobot.
"""
q = _parse_flashcards(md_text, kind='weighted')[0]
assert 'Pembahasan soal berbobot' in q['explanation']
class TestFlashcardKey:
"""Key ---FLASHCARD--- — tanpa opsi jawaban."""
def test_basic_parse(self):
md_text = """### Pertanyaan hafalan?
Ini jawabannya.
> Penjelasan tambahan.
"""
quiz = _parse_flashcards(md_text, kind='flashcard')
assert len(quiz) == 1
q = quiz[0]
assert q['type'] == 'flashcard'
assert q['quiz_type'] == 'flashcard'
assert q['options'] is None
assert 'Pertanyaan hafalan' in q['front']
assert 'Ini jawabannya' in q['back']
assert 'Penjelasan tambahan' in q['explanation']
def test_with_options_rejected(self):
md_text = """### Flashcard dengan opsi?
- [x] A
- [] B
"""
with pytest.raises(ValueError, match="tidak boleh punya opsi jawaban"):
_parse_flashcards(md_text, kind='flashcard')
class TestLegacyZeroRegression:
"""Key legacy ---QUIZ_FLASHCARD--- berperilaku 100% sama seperti sebelumnya."""
def test_legacy_bracket_number_is_not_an_option(self):
"""'- [10]' di konten legacy BUKAN opsi — tetap bagian body soal.
Kontrak zero-regression: regex legacy sengaja tidak mengenali bobot.
"""
md_text = """### Contoh format jawaban
- [10] ini bukan opsi, hanya teks
- [x] opsi asli
- [] bukan kunci
"""
quiz = _parse_flashcards(md_text, kind='legacy')
assert len(quiz) == 1
q = quiz[0]
# Hanya 2 opsi asli; '- [10]' tidak menjadi opsi
assert len(q['options']) == 2
# '- [10]' tetap ada di body question
assert '[10]' in q['question']
def test_legacy_implicit_typing(self):
"""Legacy: ada opsi → mcq, tanpa opsi → flashcard."""
md_text = """### Soal pilihan?
- [x] a
- [] b
### Kartu hafalan?
jawaban bebas
"""
quiz = _parse_flashcards(md_text, kind='legacy')
assert [q['type'] for q in quiz] == ['mcq', 'flashcard']
assert [q['quiz_type'] for q in quiz] == ['mcq', 'flashcard']
def test_legacy_validation_unchanged(self):
md_text = """### Dua kunci?
- [x] A
- [x] B
"""
with pytest.raises(ValueError, match="tepat satu opsi benar"):
_parse_flashcards(md_text, kind='legacy')
def test_legacy_flashcard_with_options_allowed(self):
"""Legacy: opsi diperbolehkan di soal tanpa opsi? Tidak — opsi = mcq.
Tapi flashcard legacy + opsi di soal LAIN tetap sah (inspeksi per soal)."""
md_text = """### Kartu A?
jawaban bebas
### Soal B?
- [x] a
- [] b
"""
quiz = _parse_flashcards(md_text, kind='legacy')
assert [q['type'] for q in quiz] == ['flashcard', 'mcq']
class TestFieldsAlwaysEmitted:
"""weight & quiz_type wajib ada di SEMUA soal/opsi, termasuk legacy."""
def test_legacy_mcq_weights_derived(self):
md_text = """### Q?
- [] a
- [x] b
"""
q = _parse_flashcards(md_text, kind='legacy')[0]
assert all('weight' in o for o in q['options'])
assert [o['weight'] for o in q['options']] == [0, 1]
assert q['quiz_type'] == 'mcq'
def test_legacy_flashcard_fields(self):
q = _parse_flashcards("### F?\njawaban\n", kind='legacy')[0]
assert q['quiz_type'] == 'flashcard'
assert q['options'] is None
def test_weighted_weights_explicit(self):
q = _parse_flashcards("### W?\n- [5] a\n- [ ] b\n", kind='weighted')[0]
assert [o['weight'] for o in q['options']] == [5, 0]
assert q['quiz_type'] == 'weighted'
class TestExtractQuizSections:
"""Ekstraksi multi-key urut dokumen + beberapa blok per key."""
def test_document_order_across_keys(self):
from services.lesson_service import _extract_quiz_sections
content = (
"prosa\n"
"---FLASHCARD---\n### F?\njawab\n---END_FLASHCARD---\n"
"tengah\n"
"---QUIZ_MCQ---\n### M?\n- [x] a\n- [] b\n---END_QUIZ_MCQ---\n"
"penutup\n"
)
sections, remaining = _extract_quiz_sections(content)
assert [kind for kind, _ in sections] == ['flashcard', 'mcq']
assert '### F?' in sections[0][1]
assert '- [x] a' in sections[1][1]
# Semua blok quiz terangkat dari konten tersisa
assert 'QUIZ_MCQ' not in remaining
assert 'FLASHCARD' not in remaining
assert 'prosa' in remaining and 'tengah' in remaining
def test_multiple_blocks_per_key(self):
from services.lesson_service import _extract_quiz_sections
content = (
"---QUIZ_MCQ---\n### M1?\n- [x] a\n- [] b\n---END_QUIZ_MCQ---\n"
"---FLASHCARD---\n### F?\njawab\n---END_FLASHCARD---\n"
"---QUIZ_MCQ---\n### M2?\n- [x] c\n- [] d\n---END_QUIZ_MCQ---\n"
)
sections, _ = _extract_quiz_sections(content)
assert [kind for kind, _ in sections] == ['mcq', 'flashcard', 'mcq']
assert '### M1?' in sections[0][1]
assert '### M2?' in sections[2][1]
def test_missing_end_marker_skipped(self):
from services.lesson_service import _extract_quiz_sections
content = "---QUIZ_MCQ---\n### M?\n- [x] a\n- [] b\n" # tanpa END
sections, remaining = _extract_quiz_sections(content)
assert sections == []
assert '### M?' in remaining
class TestRenderMarkdownStringMixedKeys:
"""Integrasi via _render_markdown_string — gabungan blok & id berantai."""
MCQ1 = "---QUIZ_MCQ---\n### M1?\n- [x] a\n- [] b\n---END_QUIZ_MCQ---\n"
CARD = "---FLASHCARD---\n### F?\njawab\n---END_FLASHCARD---\n"
MCQ2 = "---QUIZ_MCQ---\n### M2?\n- [x] c\n- [] d\n---END_QUIZ_MCQ---\n"
WEIGHTED = (
"---QUIZ_WEIGHTED---\n### W?\n- [10] a\n- [7] b\n- [ ] c\n"
"---END_QUIZ_WEIGHTED---\n"
)
def _render(self, *quiz_blocks):
from services.lesson_service import _render_markdown_string
return _render_markdown_string("# Judul\n\n" + "".join(quiz_blocks))
def test_ids_chain_across_sections(self):
parsed = self._render(self.MCQ1, self.CARD, self.MCQ2)
ids = [q['id'] for q in parsed['quiz_data']]
assert ids == ['q-0', 'q-1', 'q-2']
assert [q['quiz_type'] for q in parsed['quiz_data']] == [
'mcq', 'flashcard', 'mcq'
]
def test_tab_deduped_across_keys(self):
parsed = self._render(self.MCQ1, self.CARD, self.WEIGHTED)
assert parsed['active_tabs'].count('quiz') == 1
def test_weighted_question_renders_h3_with_id(self):
parsed = self._render(self.WEIGHTED)
q = parsed['quiz_data'][0]
assert q['question'].startswith('<h3 id=')
assert [o['weight'] for o in q['options']] == [10, 7, 0]
def test_question_heading_renders_h3(self):
"""Heading soal → <h3 id="..."> (paritas backend↔frontend)."""
parsed = self._render(self.MCQ1)
q = parsed['quiz_data'][0]
assert '<h3 id="m1">M1?</h3>' in q['question']
class TestGetLessonQuizTotal:
"""get_lesson_quiz_total: 100 utk kuis berbobot, N soal selainnya."""
def _write_lesson(self, tmp_path, monkeypatch, name, body):
p = tmp_path / f"{name}.md"
p.write_text(body, encoding='utf-8')
monkeypatch.setattr('services.lesson_service.CONTENT_DIR', str(tmp_path))
return p
def test_weighted_quiz_total_is_100(self, tmp_path, monkeypatch):
from services.lesson_service import get_lesson_quiz_total
self._write_lesson(
tmp_path, monkeypatch, 'quiz_weighted_total',
"# L\n" + TestRenderMarkdownStringMixedKeys.WEIGHTED,
)
assert get_lesson_quiz_total('quiz_weighted_total') == 100
def test_mcq_quiz_total_is_question_count(self, tmp_path, monkeypatch):
from services.lesson_service import get_lesson_quiz_total
self._write_lesson(
tmp_path, monkeypatch, 'quiz_mcq_total',
"# L\n" + TestRenderMarkdownStringMixedKeys.MCQ1,
)
assert get_lesson_quiz_total('quiz_mcq_total') == 1
def test_no_quiz_returns_none(self, tmp_path, monkeypatch):
from services.lesson_service import get_lesson_quiz_total
self._write_lesson(tmp_path, monkeypatch, 'quiz_none_total', "# L\nprosa\n")
assert get_lesson_quiz_total('quiz_none_total') is None