Files
Copies/tests/test_exam_material.py
T

151 lines
7.6 KiB
Python

import io
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from copienator import EvaluationWorkspace, ExitCode
from copienator import prompting
from copienator.commands import correction, enonce_info, gemini_for_enonce
from copienator.utils import get_exam_file_content, warn_missing_exam_material
class ExamMaterialTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
def write(self, mode, label, text):
path = self.root / mode / label
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
return path
def test_prompt_uses_section_statement_and_question_solution_for_nested_label(self):
self.write('Text', 'Ex 3', 'Entire exercise')
self.write('Text', 'Ex 3 : I)', 'Definitions and all questions in part I')
self.write('Sol', 'Ex 3', 'Entire solution')
self.write('Sol', 'Ex 3 : I)3)', 'Solution to 3)a) and 3)b)')
self.write('Persp', 'Ex 3', 'Exercise marking guidance')
for label in ('Ex 3 : I)3)a)', 'Ex 3 : I)3)b)'):
prompt = prompting.make_prompt(self.root, label)
self.assertIn('Definitions and all questions in part I', prompt)
self.assertIn('Solution to 3)a) and 3)b)', prompt)
self.assertIn('Exercise marking guidance', prompt)
self.assertNotIn('Entire exercise', prompt)
self.assertNotIn('Entire solution', prompt)
def test_exact_then_grouped_then_most_specific_parent_precedence(self):
label = 'Ex 3 : I)3)b)'
self.write('Sol', 'Ex 3', 'exercise')
self.write('Sol', 'Ex 3 : I)', 'section')
self.write('Sol', 'Ex 3 : I)3)', 'question')
group = self.write('Sol', 'Ex 3 : I)3)[a),b)]', 'grouped answers')
self.assertEqual(get_exam_file_content(self.root, 'Sol', label), 'grouped answers')
exact = self.write('Sol', label, 'exact answer')
self.assertEqual(get_exam_file_content(self.root, 'Sol', label), 'exact answer')
exact.unlink()
group.unlink()
self.assertEqual(get_exam_file_content(self.root, 'Sol', label), 'question')
def test_grouped_parent_can_cover_deeper_questions(self):
self.write('Text', 'Ex 3 : [I),II)]', 'two sections')
self.assertEqual(get_exam_file_content(self.root, 'Text', 'Ex 3 : I)3)b)'), 'two sections')
self.assertIsNone(get_exam_file_content(self.root, 'Text', 'Ex 3 : III)1)'))
def test_parent_lookup_respects_boundaries_and_ignores_rendered_files(self):
self.write('Text', 'Ex 1', 'wrong exercise')
self.write('Text', 'Ex 10 : I)1)', 'wrong question')
self.write('Text', 'Ex 10 : I)10)a).tex', 'rendered latex')
self.write('Text', 'Ex 10 : I)10)a).pdf', 'rendered pdf')
self.assertIsNone(get_exam_file_content(self.root, 'Text', 'Ex 10 : I)10)a)'))
self.write('Text', 'Ex 10 : I)', 'right section')
self.assertEqual(get_exam_file_content(self.root, 'Text', 'Ex 10 : I)10)a)'), 'right section')
def test_slash_sanitization_and_dot_hierarchies(self):
self.write('Text', 'Sheet_A', 'sheet context')
self.assertEqual(get_exam_file_content(self.root, 'Text', 'Sheet/A : 1)a)'), 'sheet context')
self.write('Sol', '1.a', 'solution')
self.assertEqual(get_exam_file_content(self.root, 'Sol', '1.a.i'), 'solution')
self.assertIsNone(get_exam_file_content(self.root, 'Sol', '1.ab.i'))
def test_missing_material_does_not_block_prompt_or_regrading_request(self):
for mode in ('Text', 'Sol'):
self.write(mode, 'Ex 1', ' \n ')
self.assertIsInstance(prompting.make_prompt(self.root, 'Ex 1 : 1)'), str)
with patch.object(prompting, 'get_single_image_bytes', return_value=b'answer') as render:
prompting.request_for_confirmed_label(self.root, 'answer.pdf', 'Ex 1 : 1)', '01')
render.assert_called_once()
def test_coverage_warns_for_every_missing_label_and_accepts_parent_material(self):
self.write('', 'labels', 'Ex 1 : 1)\nEx 2 : 1)\nEx 3 : 1)\n')
self.write('Text', 'Ex 1', 'all questions')
self.write('Sol', 'Ex 1', 'all solutions')
self.write('Text', 'Ex 2', 'questions')
self.write('Sol', 'Ex 2 : 1)', ' \n ')
output = io.StringIO()
with patch('sys.stdout', output):
warn_missing_exam_material(EvaluationWorkspace(self.root))
report = output.getvalue()
self.assertIn('1/3 labels', report)
self.assertIn('Ex 2 : 1) : corrigé absent ou vide', report)
self.assertIn('Ex 3 : 1) : énoncé absent ou vide', report)
self.assertIn('Vous pouvez continuer', report)
def test_correction_can_continue_when_material_is_missing(self):
workspace = EvaluationWorkspace(self.root)
(self.root / 'Copies').mkdir()
(self.root / 'Par label').mkdir()
(self.root / 'labels').write_text('Ex 1 : 1)\n')
args = correction.build_parser().parse_args([str(self.root)])
with (
patch.object(correction, 'discover_tasks', return_value=([('group.jpg', 'Ex 1 : 1)')], [])),
patch.object(correction, 'configure_runtime') as configure,
patch.object(correction, 'run_configured', return_value=ExitCode.SUCCESS) as run,
):
self.assertEqual(correction.run(workspace, [self.root], args, api_client=Mock()), ExitCode.SUCCESS)
configure.assert_called_once()
run.assert_called_once()
def test_personal_statement_checks_material_without_changing_success(self):
self.write('', 'enonce.tex', '%%SHEETINFO : {"id": 42}\n')
output = io.StringIO()
def fetch(url):
return io.BytesIO(b'Question### ###Rubric' if '/emacs/' in url else b'fragment')
with (
patch.object(enonce_info.urllib.request, 'urlopen', side_effect=fetch),
patch.object(enonce_info, 'compile_to_pdf'),
patch('sys.stdout', output),
):
status = enonce_info.process_directory(EvaluationWorkspace(self.root))
self.assertEqual(status, ExitCode.SUCCESS)
self.assertIn('Ex 1 : corrigé absent ou vide', output.getvalue())
self.assertIn('0/1 labels', output.getvalue())
def test_gemini_statement_checks_material_without_changing_success(self):
extraction = gemini_for_enonce.GroupedExamExtraction(groups=[[
gemini_for_enonce.QuestionItem(label='Ex 1', question_content='Question', solution_content=''),
]])
output = io.StringIO()
with (
patch.object(gemini_for_enonce, 'generate_rubrics', return_value={'Ex 1': 'Criteria'}),
patch.object(gemini_for_enonce, 'compile_existing_pdfs', return_value=ExitCode.SUCCESS),
patch('sys.stdout', output),
):
status = gemini_for_enonce.write_exam_outputs(EvaluationWorkspace(self.root), extraction, Mock())
self.assertEqual(status, ExitCode.SUCCESS)
self.assertIn('Ex 1 : corrigé absent ou vide', output.getvalue())
self.assertIn('0/1 labels', output.getvalue())
def test_compile_only_rechecks_existing_material(self):
self.write('', 'labels', 'Ex 1\n')
output = io.StringIO()
with (
patch.object(gemini_for_enonce, 'compile_existing_pdfs', return_value=ExitCode.SUCCESS),
patch('sys.stdout', output),
):
status = gemini_for_enonce.main([str(self.root), '--compile-only'])
self.assertEqual(status, ExitCode.SUCCESS)
self.assertIn('Ex 1 : énoncé absent ou vide', output.getvalue())