vault backup: 2025-12-26 02:09:22
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m29s
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m29s
This commit is contained in:
0
stroma/quiz/utils/tests/__init__.py
Normal file
0
stroma/quiz/utils/tests/__init__.py
Normal file
261
stroma/quiz/utils/tests/test_importer.py
Normal file
261
stroma/quiz/utils/tests/test_importer.py
Normal file
@@ -0,0 +1,261 @@
|
||||
import pytest
|
||||
import time
|
||||
from pathlib import Path
|
||||
from quiz.utils.importer import parse_markdown_question, import_question_file, ImportStats
|
||||
from quiz.models import Question, Option
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.import_tests
|
||||
class TestMarkdownParsing:
|
||||
"""Test parsing of various Obsidian markdown question formats"""
|
||||
|
||||
def test_parse_single_choice_question(self):
|
||||
"""Test parsing standard single choice question (SCQ)"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/scq, anatomi]
|
||||
date: 2022-01-15
|
||||
---
|
||||
What is the correct answer?
|
||||
|
||||
**Välj ett alternativ:**
|
||||
- A: Wrong answer
|
||||
- B: Correct answer
|
||||
- C: Another wrong
|
||||
|
||||
```spoiler-block:
|
||||
B
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['text'] == 'What is the correct answer?'
|
||||
assert data['correct_answer'] == 'B'
|
||||
assert data['has_answer'] is True
|
||||
assert data['question_type'] == 'scq'
|
||||
assert len(data['options']) == 3
|
||||
assert data['options'][0] == ('A', 'Wrong answer')
|
||||
assert data['options'][1] == ('B', 'Correct answer')
|
||||
|
||||
def test_parse_multiple_choice_question(self):
|
||||
"""Test parsing multiple choice question (MCQ) with 'och' separator"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/mcq, cerebrum]
|
||||
date: 2022-01-15
|
||||
---
|
||||
Vilka av följande räknas till storhjärnans basala kärnor?
|
||||
|
||||
**Välj två alternativ**
|
||||
- A: Putamen
|
||||
- B: Nucleus Ruber
|
||||
- C: Substantia nigra
|
||||
- D: Nucleus caudatus
|
||||
|
||||
```spoiler-block:
|
||||
A och D
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert 'Vilka av följande' in data['text']
|
||||
assert data['correct_answer'] == 'A,D' # Normalized to comma-separated
|
||||
assert data['has_answer'] is True
|
||||
assert data['question_type'] == 'mcq'
|
||||
assert len(data['options']) == 4
|
||||
|
||||
def test_parse_multiple_choice_comma_separated(self):
|
||||
"""Test MCQ with comma-separated answer"""
|
||||
content = """---
|
||||
tags: [frågetyp/mcq]
|
||||
---
|
||||
Select two options:
|
||||
|
||||
- A: Option A
|
||||
- B: Option B
|
||||
- C: Option C
|
||||
- D: Option D
|
||||
|
||||
```spoiler-block:
|
||||
B, C
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert data['correct_answer'] == 'B,C'
|
||||
assert data['has_answer'] is True
|
||||
|
||||
def test_parse_matching_question(self):
|
||||
"""Test parsing matching question (DND/Matching)"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/matching, anatomi, öra]
|
||||
date: 2023-05-31
|
||||
---
|
||||
**Matcha rätt funktion med rätt lob:**
|
||||
(1p för alla rätt, inga delpoäng)
|
||||
- Smak
|
||||
- Syn
|
||||
- Somatosensorik
|
||||
- Motorik
|
||||
- Hörsel
|
||||
|
||||
**Alternativ:**
|
||||
|
||||
- Lobus frontalis
|
||||
- Lobus Insularis
|
||||
- Lobus temporalis
|
||||
- Lobus parietalis
|
||||
- Lobus occipitalis
|
||||
|
||||
```spoiler-block:
|
||||
Smak: Lobus Insularis
|
||||
Syn: Lobus occipitalis
|
||||
Somatosensorik: Lobus parietalis
|
||||
Motorik: Lobus frontalis
|
||||
Hörsel: Lobus temporalis
|
||||
```
|
||||
"""
|
||||
is_matching, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_matching is True
|
||||
assert data['question_type'] == 'matching'
|
||||
assert data['has_answer'] is True
|
||||
assert len(data['left_items']) == 5
|
||||
assert len(data['top_items']) == 5
|
||||
assert len(data['correct_pairs']) == 5
|
||||
|
||||
def test_parse_textalternativ_question(self):
|
||||
"""Test text alternative question type"""
|
||||
content = """---
|
||||
tags: [frågetyp/textalternativ, öga, anatomi]
|
||||
---
|
||||
Svara på följande frågor:
|
||||
|
||||
a) Bokstaven B sitter i en lob, vilken?
|
||||
- Lobus temporalis
|
||||
- Lobus frontalis
|
||||
- Lobus parietalis
|
||||
|
||||
b) Vilket funktionellt centra återfinns där?
|
||||
- Syncentrum
|
||||
- Motorcentrum
|
||||
- Somatosensoriskt centrum
|
||||
|
||||
```spoiler-block:
|
||||
a) Lobus parietalis
|
||||
b) Somatosensoriskt centrum
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['question_type'] == 'textalternativ'
|
||||
assert data['has_answer'] is True
|
||||
assert 'Lobus parietalis' in data['correct_answer']
|
||||
assert 'Somatosensoriskt centrum' in data['correct_answer']
|
||||
|
||||
def test_parse_textfalt_question(self):
|
||||
"""Test text field (fill-in) question type"""
|
||||
content = """---
|
||||
tags: [frågetyp/textfält, öga]
|
||||
---
|
||||
**Fyll i rätt siffra!**
|
||||
|
||||
a) Vilken siffra pekar på gula fläcken?
|
||||
b) Vilken siffra pekar på choroidea?
|
||||
|
||||
```spoiler-block:
|
||||
a) 7
|
||||
b) 6
|
||||
```
|
||||
"""
|
||||
is_question, data = parse_markdown_question(Path("test.md"), content)
|
||||
|
||||
assert is_question is True
|
||||
assert data['question_type'] == 'textfält'
|
||||
assert data['has_answer'] is True
|
||||
assert '7' in data['correct_answer']
|
||||
assert '6' in data['correct_answer']
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.import_tests
|
||||
class TestQuestionImport:
|
||||
"""Test actual import of questions to database"""
|
||||
|
||||
def test_import_single_question(self, tmp_path):
|
||||
"""Test importing a single question file"""
|
||||
question_file = tmp_path / "question1.md"
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Test question?
|
||||
|
||||
- A: Correct
|
||||
- B: Wrong
|
||||
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
""")
|
||||
|
||||
stats = ImportStats()
|
||||
result = import_question_file(question_file, tmp_path, stats, force=True)
|
||||
|
||||
assert result in ['imported', 'updated']
|
||||
assert stats.questions_with_answers == 1
|
||||
|
||||
# Verify in database
|
||||
question = Question.objects.get(text='Test question?')
|
||||
assert question.correct_answer == 'A'
|
||||
assert question.options.count() == 2
|
||||
|
||||
def test_mtime_tracking(self, tmp_path):
|
||||
"""Test that file modification time is tracked"""
|
||||
question_file = tmp_path / "question4.md"
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
What is the correct answer?
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
""")
|
||||
|
||||
stats = ImportStats()
|
||||
import_question_file(question_file, tmp_path, stats, force=True)
|
||||
|
||||
question = Question.objects.get(text='What is the correct answer?')
|
||||
assert question.file_mtime == question_file.stat().st_mtime
|
||||
|
||||
def test_update_existing_question(self, tmp_path):
|
||||
"""Test updating an existing question"""
|
||||
question_file = tmp_path / "question5.md"
|
||||
|
||||
# Initial import
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Question to update?
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
""")
|
||||
|
||||
import_question_file(question_file, tmp_path, ImportStats(), force=True)
|
||||
|
||||
# Update the file
|
||||
time.sleep(0.1)
|
||||
question_file.write_text("""---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Question to update?
|
||||
```spoiler-block:
|
||||
B
|
||||
```
|
||||
""")
|
||||
|
||||
stats = ImportStats()
|
||||
result = import_question_file(question_file, tmp_path, stats, force=False)
|
||||
|
||||
assert result == 'updated'
|
||||
assert Question.objects.get(text='Question to update?').correct_answer == 'B'
|
||||
537
stroma/quiz/utils/tests/test_question_parser.py
Normal file
537
stroma/quiz/utils/tests/test_question_parser.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""
|
||||
Comprehensive test suite for the question_parser module.
|
||||
|
||||
This test suite uses pytest's parametrize decorator to test multiple scenarios
|
||||
with minimal code duplication. It covers:
|
||||
|
||||
1. Node class:
|
||||
- Initialization with different token types
|
||||
- Attribute handling
|
||||
- Children node processing
|
||||
- String representation (__repr__)
|
||||
- Text extraction from nested structures
|
||||
|
||||
2. parse_question function:
|
||||
- Metadata parsing (tags, dates, etc.)
|
||||
- Raw content extraction
|
||||
- Different question types (MCQ, SCQ, text field, matching)
|
||||
- Questions with images
|
||||
- Edge cases (empty content, missing frontmatter)
|
||||
- Document structure preservation
|
||||
|
||||
3. ParsedQuestion dataclass:
|
||||
- Default values
|
||||
- Initialization with custom values
|
||||
|
||||
4. Real exam questions:
|
||||
- Parsing actual exam questions from the content directory
|
||||
- Validation of all short-named question files
|
||||
|
||||
Test execution:
|
||||
pytest tests/test_question_parser.py -v # Verbose output
|
||||
pytest tests/test_question_parser.py -k "mcq" # Run only MCQ tests
|
||||
pytest tests/test_question_parser.py --collect-only # List all tests
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import tempfile
|
||||
import pytest
|
||||
from quiz.utils.question_parser import Node, ParsedQuestion, parse_question
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for test files"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
yield pathlib.Path(tmpdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_question_file(temp_dir):
|
||||
"""Factory fixture to create question files"""
|
||||
def _create_file(filename: str, content: str) -> pathlib.Path:
|
||||
file_path = temp_dir / filename
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
return file_path
|
||||
return _create_file
|
||||
|
||||
|
||||
class TestNode:
|
||||
"""Test the Node class"""
|
||||
|
||||
@pytest.mark.parametrize("token,expected_type,expected_raw", [
|
||||
({"type": "paragraph"}, "paragraph", ""),
|
||||
({"type": "heading", "raw": "Test Heading"}, "heading", "Test Heading"),
|
||||
({"type": "text", "raw": "Some text"}, "text", "Some text"),
|
||||
({"type": "list"}, "list", ""),
|
||||
])
|
||||
def test_node_initialization(self, token, expected_type, expected_raw):
|
||||
"""Test Node initialization with different token types"""
|
||||
node = Node(token)
|
||||
assert node.type == expected_type
|
||||
assert node.raw == expected_raw
|
||||
|
||||
@pytest.mark.parametrize("token,expected_attrs", [
|
||||
({"type": "block_code", "attrs": {"info": "spoiler-block:"}}, {"info": "spoiler-block:"}),
|
||||
({"type": "paragraph"}, {}),
|
||||
({"type": "heading", "attrs": {"level": 2}}, {"level": 2}),
|
||||
])
|
||||
def test_node_attributes(self, token, expected_attrs):
|
||||
"""Test Node attributes handling"""
|
||||
node = Node(token)
|
||||
assert node.attrs == expected_attrs
|
||||
|
||||
def test_node_children(self):
|
||||
"""Test Node children handling"""
|
||||
token = {
|
||||
"type": "paragraph",
|
||||
"children": [
|
||||
{"type": "text", "raw": "Hello "},
|
||||
{"type": "text", "raw": "World"},
|
||||
]
|
||||
}
|
||||
node = Node(token)
|
||||
assert len(node.children) == 2
|
||||
assert node.children[0].type == "text"
|
||||
assert node.children[0].raw == "Hello "
|
||||
assert node.children[1].type == "text"
|
||||
assert node.children[1].raw == "World"
|
||||
|
||||
@pytest.mark.parametrize("token,expected_repr_contains", [
|
||||
({"type": "text", "raw": "test"}, "Text(raw='test')"),
|
||||
({"type": "paragraph"}, "Paragraph()"),
|
||||
({"type": "block_code", "attrs": {"info": "python"}}, "BlockCode(attrs={'info': 'python'})"),
|
||||
])
|
||||
def test_node_repr(self, token, expected_repr_contains):
|
||||
"""Test Node __repr__ method"""
|
||||
node = Node(token)
|
||||
assert repr(node) == expected_repr_contains
|
||||
|
||||
@pytest.mark.parametrize("token,expected_text", [
|
||||
({"type": "text", "raw": "Simple text"}, "Simple text"),
|
||||
(
|
||||
{
|
||||
"type": "paragraph",
|
||||
"children": [
|
||||
{"type": "text", "raw": "Hello "},
|
||||
{"type": "text", "raw": "World"},
|
||||
]
|
||||
},
|
||||
"Hello World"
|
||||
),
|
||||
(
|
||||
{
|
||||
"type": "paragraph",
|
||||
"children": [
|
||||
{"type": "text", "raw": "Nested "},
|
||||
{
|
||||
"type": "strong",
|
||||
"children": [{"type": "text", "raw": "bold"}]
|
||||
},
|
||||
{"type": "text", "raw": " text"},
|
||||
]
|
||||
},
|
||||
"Nested bold text"
|
||||
),
|
||||
])
|
||||
def test_node_text_property(self, token, expected_text):
|
||||
"""Test Node text property extraction"""
|
||||
node = Node(token)
|
||||
assert node.text == expected_text
|
||||
|
||||
|
||||
class TestParseQuestion:
|
||||
"""Test the parse_question function"""
|
||||
|
||||
@pytest.mark.parametrize("content,expected_tags", [
|
||||
(
|
||||
"""---
|
||||
tags: [ah2, provfråga, frågetyp/mcq]
|
||||
date: 2022-01-15
|
||||
---
|
||||
Question content""",
|
||||
["ah2", "provfråga", "frågetyp/mcq"]
|
||||
),
|
||||
(
|
||||
"""---
|
||||
tags:
|
||||
- ah2
|
||||
- provfråga
|
||||
- frågetyp/scq
|
||||
date: 2023-05-31
|
||||
---
|
||||
Question content""",
|
||||
["ah2", "provfråga", "frågetyp/scq"]
|
||||
),
|
||||
])
|
||||
def test_parse_metadata_tags(self, create_question_file, content, expected_tags):
|
||||
"""Test parsing of metadata tags in different formats"""
|
||||
file_path = create_question_file("test.md", content)
|
||||
question = parse_question(file_path)
|
||||
assert question.metadata["tags"] == expected_tags
|
||||
|
||||
@pytest.mark.parametrize("content,expected_date", [
|
||||
(
|
||||
"""---
|
||||
tags: [ah2]
|
||||
date: 2022-01-15
|
||||
---
|
||||
Content""",
|
||||
"2022-01-15"
|
||||
),
|
||||
(
|
||||
"""---
|
||||
tags: [ah2]
|
||||
date: 2023-05-31
|
||||
---
|
||||
Content""",
|
||||
"2023-05-31"
|
||||
),
|
||||
])
|
||||
def test_parse_metadata_date(self, create_question_file, content, expected_date):
|
||||
"""Test parsing of metadata date"""
|
||||
file_path = create_question_file("test.md", content)
|
||||
question = parse_question(file_path)
|
||||
assert str(question.metadata["date"]) == expected_date
|
||||
|
||||
@pytest.mark.parametrize("content,expected_raw", [
|
||||
(
|
||||
"""---
|
||||
tags: [ah2]
|
||||
---
|
||||
Simple question""",
|
||||
"Simple question"
|
||||
),
|
||||
(
|
||||
"""---
|
||||
tags: [ah2]
|
||||
---
|
||||
Question with **bold** text""",
|
||||
"Question with **bold** text"
|
||||
),
|
||||
])
|
||||
def test_parse_raw_content(self, create_question_file, content, expected_raw):
|
||||
"""Test parsing of raw content"""
|
||||
file_path = create_question_file("test.md", content)
|
||||
question = parse_question(file_path)
|
||||
assert question.raw_content.strip() == expected_raw
|
||||
|
||||
def test_parse_mcq_question(self, create_question_file):
|
||||
"""Test parsing a complete MCQ question"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/mcq, cerebrum]
|
||||
date: 2022-01-15
|
||||
---
|
||||
Vilka av följande räknas till storhjärnans basala kärnor?
|
||||
|
||||
**Välj två alternativ**
|
||||
- A: Putamen
|
||||
- B: Nucleus Ruber
|
||||
- C: Substantia nigra
|
||||
- D: Nucleus caudatus
|
||||
|
||||
```spoiler-block:
|
||||
A och D
|
||||
```
|
||||
"""
|
||||
file_path = create_question_file("mcq.md", content)
|
||||
question = parse_question(file_path)
|
||||
|
||||
assert question.metadata["tags"] == ["ah2", "provfråga", "frågetyp/mcq", "cerebrum"]
|
||||
assert len(question.nodes) > 0
|
||||
|
||||
# Find paragraph nodes
|
||||
paragraphs = [n for n in question.nodes if n.type == "paragraph"]
|
||||
assert len(paragraphs) > 0
|
||||
|
||||
# Find list nodes
|
||||
lists = [n for n in question.nodes if n.type == "list"]
|
||||
assert len(lists) > 0
|
||||
|
||||
# Find spoiler block
|
||||
code_blocks = [n for n in question.nodes if n.type == "block_code"]
|
||||
assert len(code_blocks) > 0
|
||||
spoiler = code_blocks[0]
|
||||
assert spoiler.attrs.get("info") == "spoiler-block:"
|
||||
assert "A och D" in spoiler.raw
|
||||
|
||||
def test_parse_scq_question(self, create_question_file):
|
||||
"""Test parsing a single choice question"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/scq, histologi]
|
||||
date: 2022-06-01
|
||||
---
|
||||
Vilken del av CNS syns i bild?
|
||||
- A: Cerebellum
|
||||
- B: Diencephalon
|
||||
- C: Medulla spinalis
|
||||
- D: Cerebrum
|
||||
- E: Pons
|
||||
|
||||
```spoiler-block:
|
||||
A
|
||||
```
|
||||
"""
|
||||
file_path = create_question_file("scq.md", content)
|
||||
question = parse_question(file_path)
|
||||
|
||||
assert "frågetyp/scq" in question.metadata["tags"]
|
||||
lists = [n for n in question.nodes if n.type == "list"]
|
||||
assert len(lists) > 0
|
||||
|
||||
def test_parse_text_field_question(self, create_question_file):
|
||||
"""Test parsing a text field question"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/textfält, öga, anatomi]
|
||||
date: 2022-01-15
|
||||
---
|
||||
![[image-2.png|301x248]]
|
||||
**Fyll i rätt siffra!**
|
||||
|
||||
(0.5p per rätt svar, inga avdrag för fel svar):
|
||||
|
||||
a) Vilken siffra pekar på gula fläcken?
|
||||
b) Vilken siffra pekar på choroidea?
|
||||
|
||||
```spoiler-block:
|
||||
a) 7
|
||||
b) 6
|
||||
```
|
||||
"""
|
||||
file_path = create_question_file("textfield.md", content)
|
||||
question = parse_question(file_path)
|
||||
|
||||
assert "frågetyp/textfält" in question.metadata["tags"]
|
||||
assert len(question.nodes) > 0
|
||||
|
||||
def test_parse_matching_question(self, create_question_file):
|
||||
"""Test parsing a matching question"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/matching, histologi]
|
||||
date: 2023-05-31
|
||||
---
|
||||
Vilka av följande stödjeceller finns i CNS? Markera JA eller NEJ för varje angiven celltyp:
|
||||
(1p för alla rätt, inga delpoäng)
|
||||
|
||||
- a) oligodendrocyter
|
||||
- b) Astrocyter
|
||||
- c) satellitceller
|
||||
- d) ependymceller
|
||||
- e) mikroglia
|
||||
- f) Schwannceller
|
||||
|
||||
- JA, finn i CNS
|
||||
- NEJ, finns inte i CNS
|
||||
|
||||
```spoiler-block:
|
||||
a) JA, finn i CNS
|
||||
b) JA, finn i CNS
|
||||
c) NEJ, finns inte i CNS
|
||||
d) JA, finn i CNS
|
||||
e) JA, finn i CNS
|
||||
f) NEJ, finns inte i CNS
|
||||
```
|
||||
"""
|
||||
file_path = create_question_file("matching.md", content)
|
||||
question = parse_question(file_path)
|
||||
|
||||
assert "frågetyp/matching" in question.metadata["tags"]
|
||||
lists = [n for n in question.nodes if n.type == "list"]
|
||||
assert len(lists) > 0
|
||||
|
||||
def test_parse_question_with_image(self, create_question_file):
|
||||
"""Test parsing a question with embedded images"""
|
||||
content = """---
|
||||
tags: [ah2, provfråga, frågetyp/textfält, öra, anatomi, bild]
|
||||
date: 2022-01-15
|
||||
---
|
||||
![[image-4.png|292x316]]
|
||||
**Fyll i rätt siffra !**
|
||||
|
||||
(0.5p per rätt svar, inga avdrag för fel svar):
|
||||
|
||||
a) Vilken siffra pekar på incus? (1..19)
|
||||
b) Vilken siffra pekar på tuba auditiva? (1..19)
|
||||
|
||||
```spoiler-block:
|
||||
a) 7
|
||||
b) 18
|
||||
```
|
||||
"""
|
||||
file_path = create_question_file("image_q.md", content)
|
||||
question = parse_question(file_path)
|
||||
|
||||
assert "bild" in question.metadata["tags"]
|
||||
assert "![[image-4.png" in question.raw_content
|
||||
embed = question.nodes[0].children[0]
|
||||
assert embed.type == "embed"
|
||||
assert embed.attrs == {
|
||||
"filename": "image-4.png",
|
||||
"width": 292,
|
||||
"height": 316
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("invalid_content", [
|
||||
"", # Empty content
|
||||
"No frontmatter", # No frontmatter
|
||||
"---\n---\n", # Empty frontmatter
|
||||
])
|
||||
def test_parse_edge_cases(self, create_question_file, invalid_content):
|
||||
"""Test parsing edge cases"""
|
||||
file_path = create_question_file("edge.md", invalid_content)
|
||||
question = parse_question(file_path)
|
||||
assert isinstance(question, ParsedQuestion)
|
||||
|
||||
def test_parse_question_preserves_structure(self, create_question_file):
|
||||
"""Test that parsing preserves the document structure"""
|
||||
content = """---
|
||||
tags: [ah2]
|
||||
---
|
||||
# Heading
|
||||
|
||||
Paragraph text
|
||||
|
||||
- List item 1
|
||||
- List item 2
|
||||
|
||||
```spoiler-block:
|
||||
Answer
|
||||
```
|
||||
"""
|
||||
file_path = create_question_file("structure.md", content)
|
||||
question = parse_question(file_path)
|
||||
|
||||
node_types = [n.type for n in question.nodes]
|
||||
assert "heading" in node_types
|
||||
assert "paragraph" in node_types
|
||||
assert "list" in node_types
|
||||
assert "block_code" in node_types
|
||||
|
||||
|
||||
class TestParsedQuestionDataclass:
|
||||
"""Test the ParsedQuestion dataclass"""
|
||||
|
||||
def test_parsed_question_defaults(self):
|
||||
"""Test ParsedQuestion default values"""
|
||||
question = ParsedQuestion()
|
||||
assert question.metadata == {}
|
||||
assert question.raw_content == ""
|
||||
assert question.nodes == []
|
||||
|
||||
def test_parsed_question_initialization(self):
|
||||
"""Test ParsedQuestion initialization with values"""
|
||||
metadata = {"tags": ["test"], "date": "2022-01-15"}
|
||||
content = "Test content"
|
||||
nodes = [Node({"type": "paragraph"})]
|
||||
|
||||
question = ParsedQuestion(
|
||||
metadata=metadata,
|
||||
raw_content=content,
|
||||
nodes=nodes
|
||||
)
|
||||
|
||||
assert question.metadata == metadata
|
||||
assert question.raw_content == content
|
||||
assert question.nodes == nodes
|
||||
|
||||
|
||||
class TestRealQuestions:
|
||||
"""Test parsing real questions from the exam files"""
|
||||
|
||||
@pytest.fixture
|
||||
def exam_dir(self):
|
||||
"""Get the real exam directory"""
|
||||
root = pathlib.Path(__file__).parent.parent.parent
|
||||
exam_path = root / "content" / "Anatomi & Histologi 2" / "Gamla tentor"
|
||||
if exam_path.exists():
|
||||
return exam_path
|
||||
pytest.skip("Exam directory not found")
|
||||
|
||||
@pytest.mark.parametrize("exam_date,question_num", [
|
||||
("2022-01-15", "1"),
|
||||
("2022-01-15", "2"),
|
||||
("2022-01-15", "3"),
|
||||
("2022-01-15", "4"),
|
||||
("2022-06-01", "8"),
|
||||
])
|
||||
def test_parse_real_exam_questions(self, exam_dir, exam_date, question_num):
|
||||
"""Test parsing real exam questions"""
|
||||
file_path = exam_dir / exam_date / f"{question_num}.md"
|
||||
if not file_path.exists():
|
||||
pytest.skip(f"Question file {file_path} not found")
|
||||
|
||||
question = parse_question(file_path)
|
||||
|
||||
# Verify metadata exists and has required fields
|
||||
assert "tags" in question.metadata
|
||||
assert isinstance(question.metadata["tags"], list)
|
||||
assert "ah2" in question.metadata["tags"]
|
||||
assert "provfråga" in question.metadata["tags"]
|
||||
|
||||
# Verify content was parsed
|
||||
assert len(question.raw_content) > 0
|
||||
assert len(question.nodes) > 0
|
||||
|
||||
def test_parse_all_short_named_questions(self, exam_dir):
|
||||
"""Test parsing all questions with short filenames (1-2 chars)"""
|
||||
questions_found = 0
|
||||
|
||||
for file in sorted(exam_dir.glob("*/*.md")):
|
||||
if len(file.stem) <= 2 and file.stem.isdigit():
|
||||
question = parse_question(file)
|
||||
assert isinstance(question, ParsedQuestion)
|
||||
assert "tags" in question.metadata
|
||||
questions_found += 1
|
||||
|
||||
# Ensure we found at least some questions
|
||||
assert questions_found > 0, "No exam questions found to test"
|
||||
|
||||
|
||||
class TestNodeTextExtraction:
|
||||
"""Test text extraction from complex node structures"""
|
||||
|
||||
@pytest.mark.parametrize("token,expected_text", [
|
||||
# Simple text
|
||||
({"type": "text", "raw": "Hello"}, "Hello"),
|
||||
|
||||
# Paragraph with multiple text children
|
||||
(
|
||||
{
|
||||
"type": "paragraph",
|
||||
"children": [
|
||||
{"type": "text", "raw": "A "},
|
||||
{"type": "text", "raw": "B "},
|
||||
{"type": "text", "raw": "C"},
|
||||
]
|
||||
},
|
||||
"A B C"
|
||||
),
|
||||
|
||||
# Nested formatting
|
||||
(
|
||||
{
|
||||
"type": "paragraph",
|
||||
"children": [
|
||||
{"type": "text", "raw": "Normal "},
|
||||
{
|
||||
"type": "emphasis",
|
||||
"children": [{"type": "text", "raw": "italic"}]
|
||||
},
|
||||
{"type": "text", "raw": " "},
|
||||
{
|
||||
"type": "strong",
|
||||
"children": [{"type": "text", "raw": "bold"}]
|
||||
},
|
||||
]
|
||||
},
|
||||
"Normal italic bold"
|
||||
),
|
||||
|
||||
# Empty node
|
||||
({"type": "paragraph", "children": []}, ""),
|
||||
])
|
||||
def test_complex_text_extraction(self, token, expected_text):
|
||||
"""Test text extraction from complex nested structures"""
|
||||
node = Node(token)
|
||||
assert node.text == expected_text
|
||||
|
||||
187
stroma/quiz/utils/tests/test_unified_parser.py
Normal file
187
stroma/quiz/utils/tests/test_unified_parser.py
Normal file
@@ -0,0 +1,187 @@
|
||||
import datetime
|
||||
from quiz.utils.unified_parser import UnifiedParser, QuestionType
|
||||
|
||||
def test_parse_mcq_question():
|
||||
content = """---
|
||||
tags: [frågetyp/mcq, ah2]
|
||||
date: 2024-03-21
|
||||
---
|
||||
Question?
|
||||
- A: Yes
|
||||
- B: No
|
||||
- C: Maybe
|
||||
- D: Never
|
||||
```spoiler-block:
|
||||
A och D
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.MCQ
|
||||
assert data.question == "Question?"
|
||||
assert data.answer == ["A", "D"]
|
||||
assert data.num_questions == 1
|
||||
assert data.is_complete is True
|
||||
assert data.options == ["A: Yes", "B: No", "C: Maybe", "D: Never"]
|
||||
assert data.metadata == {"tags": ["frågetyp/mcq", "ah2"], "date": datetime.date(2024, 3, 21)}
|
||||
assert not data.sub_questions
|
||||
|
||||
def test_parse_scq_question():
|
||||
content = """---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
Pick one:
|
||||
- A: One
|
||||
- B: Two
|
||||
```spoiler-block:
|
||||
B
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.SCQ
|
||||
assert data.question == "Pick one:"
|
||||
assert data.answer == "B"
|
||||
assert data.num_questions == 1
|
||||
assert data.is_complete is True
|
||||
assert data.options == ["A: One", "B: Two"]
|
||||
assert not data.sub_questions
|
||||
|
||||
def test_parse_textfält_question():
|
||||
content = """---
|
||||
tags: [frågetyp/textfält]
|
||||
---
|
||||
Name these:
|
||||
a) Part 1
|
||||
b) Part 2
|
||||
```spoiler-block:
|
||||
a) Left
|
||||
b) Right
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.TEXTFÄLT
|
||||
assert data.question == "Name these:"
|
||||
assert data.answer == ["a) Left", "b) Right"]
|
||||
assert data.num_questions == 2
|
||||
assert len(data.sub_questions) == 2
|
||||
assert data.sub_questions[0].id == "a"
|
||||
assert data.sub_questions[0].text == "Part 1"
|
||||
assert data.sub_questions[0].answer == "a) Left"
|
||||
assert data.sub_questions[0].options is None
|
||||
|
||||
def test_parse_matching_question():
|
||||
content = """---
|
||||
tags: [frågetyp/matching]
|
||||
---
|
||||
Match:
|
||||
- 1
|
||||
- 2
|
||||
- A
|
||||
- B
|
||||
```spoiler-block:
|
||||
1: A
|
||||
2: B
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.MATCHING
|
||||
assert data.question == "Match:"
|
||||
assert data.answer == [["1", "A"], ["2", "B"]]
|
||||
assert data.num_questions == 1
|
||||
assert data.options == ["1", "2", "A", "B"]
|
||||
assert not data.sub_questions
|
||||
|
||||
def test_parse_question_with_image_and_instruction():
|
||||
content = """---
|
||||
tags: [frågetyp/scq]
|
||||
---
|
||||
**Välj ett alternativ:**
|
||||
|
||||
![[brain.png|300]]
|
||||
|
||||
What is this?
|
||||
- A: Brain
|
||||
- B: Heart
|
||||
```spoiler-block:
|
||||
A
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.SCQ
|
||||
assert data.question == "What is this?"
|
||||
assert data.instruction == "Välj ett alternativ:"
|
||||
assert data.image == "![[brain.png]]"
|
||||
assert data.is_complete is True
|
||||
|
||||
def test_parse_field_question_with_ranges():
|
||||
content = """---
|
||||
tags: [frågetyp/sifferfält]
|
||||
---
|
||||
Identify the structures:
|
||||
|
||||
a) Arachnoidea? (1..10)
|
||||
(0.5 p)
|
||||
b) Cortex cerebri (1..10)
|
||||
(0.5 p)
|
||||
|
||||
```spoiler-block:
|
||||
a) 7
|
||||
b) 3
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.SIFFERFÄLT
|
||||
assert data.num_questions == 2
|
||||
assert len(data.sub_questions) == 2
|
||||
|
||||
# Part A
|
||||
assert data.sub_questions[0].id == "a"
|
||||
assert data.sub_questions[0].text == "Arachnoidea?"
|
||||
assert data.sub_questions[0].options == [str(x) for x in range(1, 11)]
|
||||
assert data.sub_questions[0].answer == "a) 7"
|
||||
|
||||
# Part B
|
||||
assert data.sub_questions[1].id == "b"
|
||||
assert data.sub_questions[1].text == "Cortex cerebri"
|
||||
assert data.sub_questions[1].options == [str(x) for x in range(1, 11)]
|
||||
assert data.sub_questions[1].answer == "b) 3"
|
||||
|
||||
def test_parse_field_question_with_list_options():
|
||||
content = """---
|
||||
tags: [frågetyp/sifferfält]
|
||||
---
|
||||
a) First (A, B, C)
|
||||
b) Second (1, 2, 3)
|
||||
|
||||
```spoiler-block:
|
||||
a) A
|
||||
b) 2
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.sub_questions[0].options == ["A", "B", "C"]
|
||||
assert data.sub_questions[1].options == ["1", "2", "3"]
|
||||
|
||||
def test_parse_hotspot_question():
|
||||
content = """---
|
||||
tags: [frågetyp/hotspot]
|
||||
---
|
||||
Klicka på hippocampus!
|
||||
|
||||
```spoiler-block:
|
||||
![[brain_atlas.png]]
|
||||
Det här är hippocampus.
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.type == QuestionType.HOTSPOT
|
||||
assert data.answer == "Det här är hippocampus."
|
||||
assert data.answer_image == "![[brain_atlas.png]]"
|
||||
assert data.is_complete is True
|
||||
|
||||
def test_completeness_missing_sub_questions():
|
||||
content = """---
|
||||
tags: [frågetyp/textfält]
|
||||
---
|
||||
a) one
|
||||
b) two
|
||||
```spoiler-block:
|
||||
a) found
|
||||
```"""
|
||||
data = UnifiedParser(content).parse()
|
||||
assert data.num_questions == 2
|
||||
assert data.is_complete is False
|
||||
assert len(data.sub_questions) == 2
|
||||
assert data.sub_questions[0].answer == "a) found"
|
||||
assert data.sub_questions[1].answer is None
|
||||
Reference in New Issue
Block a user