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())