sahil-12kumar/IL_CMS_Tools
1
1"""The shipped Word template must stay a clean paste-over target for SMEs.2 3samples/sample_questions.docx is generated by make_sample_docx.py; regenerate with4`python make_sample_docx.py --check` if a parser change moves it.5"""6import re7from pathlib import Path8 9import pytest10 11ROOT = Path(__file__).resolve().parent.parent12SAMPLE = ROOT / 'samples' / 'sample_questions.docx'13 14 15@pytest.fixture(scope='module')16def sample(app_mod):17 if not SAMPLE.exists():18 pytest.skip(f'{SAMPLE.name} missing — run make_sample_docx.py')19 qs = app_mod.parse_word(str(SAMPLE))20 app_mod.validate_questions(qs)21 return qs22 23 24@pytest.fixture(scope='module')25def paragraphs():26 if not SAMPLE.exists():27 pytest.skip(f'{SAMPLE.name} missing — run make_sample_docx.py')28 from docx import Document29 return [p.text.strip() for p in Document(str(SAMPLE)).paragraphs if p.text.strip()]30 31 32def test_all_five_questions_parse(sample):33 assert len(sample) == 5, [q['question_text'][:40] for q in sample]34 35 36def test_no_question_is_flagged(sample):37 bad = [(i, q['issues']) for i, q in enumerate(sample, 1) if q['issues']]38 assert bad == [], f'the template must be clean: {bad}'39 40 41def test_every_question_has_an_answer_and_a_solution(sample):42 for i, q in enumerate(sample, 1):43 assert str(q['correct_answer']).strip(), f'Q{i} has no answer key'44 assert q['solution'].strip(), f'Q{i} has no solution'45 46 47# ── only the four line kinds an SME needs ────────────────────────────────────48def test_template_has_no_headings_or_metadata_lines(paragraphs):49 """question -> options -> answer -> solution, and nothing else. No subject50 heading, no title, no 'Question Type:'/'Difficulty Level:' lines."""51 banned = re.compile(r'^(MATHEMATICS|PHYSICS|CHEMISTRY|BOTANY|ZOOLOGY|BIOLOGY)\b'52 r'|^Question\s*Type\s*[:\-]|^Difficulty', re.I)53 offenders = [p for p in paragraphs if banned.match(p)]54 assert offenders == [], offenders55 56 57def test_template_opens_with_a_question(paragraphs):58 """Any other opening line would itself be parsed as a question."""59 assert re.match(r'^(?:Q\s*)?\d+[.:]\s*\S', paragraphs[0]), paragraphs[0]60 61 62def test_no_phantom_questions(sample):63 """Every parsed question must have an answer — a phantom made from a stray64 line (a heading, or a solution paragraph) would have none."""65 for i, q in enumerate(sample, 1):66 assert q['correct_answer'], f'Q{i} looks like a phantom question'67 68 69# ── both option marker styles ────────────────────────────────────────────────70def test_template_shows_both_option_styles(paragraphs):71 joined = '\n'.join(paragraphs)72 assert re.search(r'^[a-d]\)\s', joined, re.M), 'no lowercase a) b) c) d) example'73 assert re.search(r'^\([A-D]\)\s', joined, re.M), 'no (A) (B) (C) (D) example'74 assert re.search(r'^1\)\s.*\s2\)\s', joined, re.M), 'no numbered 1) 2) 3) 4) example'75 76 77@pytest.mark.parametrize('idx,expected_answer', [(0, 'b'), (1, '3'), (4, 'B')])78def test_both_styles_parse_their_options_and_answer(app_mod, sample, idx, expected_answer):79 q = sample[idx]80 filled = sum(1 for k in 'abcd' if app_mod._option_filled(q.get(f'option_{k}', '')))81 assert filled == 4, f'Q{idx + 1}: {filled}/4 options'82 assert q['correct_answer'] == expected_answer83 # the marker itself must not survive into the option HTML84 assert not re.match(r'^<p>\s*\(?[a-dA-D1-4]\)', q['option_a']), q['option_a']85 86 87def test_letter_answers_map_to_positions_in_the_payload(app_mod, sample):88 """'Answer: b' and 'Answer: B' must both reach the CMS as option 2."""89 assert app_mod.build_payload(sample[0])['question']['correct_answer'] == ['2']90 assert app_mod.build_payload(sample[4])['question']['correct_answer'] == ['2']91 92 93# ── types, maths, figure, band ───────────────────────────────────────────────94def test_types_are_inferred_without_metadata_lines(app_mod, sample):95 """4 options -> single correct; no options -> numerical. No type hint needed."""96 types = [app_mod.normalise_qtype(q['question_type']) for q in sample]97 assert types.count('single correct mcq') == 398 assert types.count('numerical value question') == 299 100 101def test_numerical_questions_have_no_options(app_mod, sample):102 for i, q in enumerate(sample, 1):103 if 'numerical' in app_mod.normalise_qtype(q['question_type']):104 filled = sum(1 for k in 'abcd'105 if app_mod._option_filled(q.get(f'option_{k}', '')))106 assert filled == 0, f'Q{i} is numerical but has {filled} options'107 108 109def test_template_demonstrates_maths_and_a_figure(sample):110 html = ''.join(q['question_text'] + q['solution'] +111 ''.join(q.get(f'option_{k}', '') for k in 'abcd') for q in sample)112 if '<math' not in html:113 pytest.skip('OMML2MML.XSL not available — see vendor/README.md')114 assert '<img src="data:image/png;base64,' in html, 'the figure did not survive'115 116 117def test_nothing_failed_to_convert(app_mod, sample):118 fields = [q[f] for q in sample for f in119 ('question_text', 'option_a', 'option_b', 'option_c', 'option_d', 'solution')]120 assert app_mod.conversion_failures(*fields) == []121 122 123def test_numerical_band_is_carried_through(app_mod, sample):124 """Q4 is written "Answer: 0.28-0.32" — the hyphen form is the only band the125 answer-line pattern accepts."""126 banded = [q for q in sample if q.get('answer_range')]127 assert len(banded) == 1, 'expected exactly one banded numerical question'128 ranges = app_mod.build_payload(banded[0])['question']['solutionRanges']129 assert ranges == [{'name': 'Default Range', 'start': 0.28, 'end': 0.32}], ranges130 131 132def test_every_question_builds_a_valid_payload(app_mod, sample):133 for i, q in enumerate(sample, 1):134 p = app_mod.build_payload(q)135 assert p['questiontype'], f'Q{i}: question type did not resolve to a CMS uuid'136 assert p['question']['correct_answer'], f'Q{i}: empty answer in payload'137 assert p['question']['solution'], f'Q{i}: empty solution in payload'138 assert p['status'] == 'draft'139 