Bnot fixes ; Missing énoncés fixes.

This commit is contained in:
sebastien committed 2026-10-23 11:15:05 +02:00
1 parent f39868207d
commit 55d5cf16c9
18 files changed
+486 -44

No files matched your search

+26
View File
@@ -12,6 +12,32 @@ from copienator.commands import add_final_score
class AddFinalScoreTests(unittest.TestCase):
def test_merged_histogram_is_copied_only_when_selected(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
histogram = root / 'Staging/histogramme.pdf'
histogram.parent.mkdir()
histogram.write_bytes(b'histogram')
merged = histogram.with_name('merged_histogramme.pdf')
merged.write_bytes(b'merged')
output = root / 'output'
output.mkdir()
with patch.object(add_final_score, 'HISTOGRAM_PATH', histogram):
add_final_score.copy_histogram(output)
self.assertFalse((output / merged.name).exists())
add_final_score.copy_histogram(output, copy_merged_histogramme=True)
self.assertEqual((output / 'histogramme.pdf').read_bytes(), b'histogram')
self.assertEqual((output / merged.name).read_bytes(), b'merged')
def test_cli_forwards_merged_histogram_option(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
with patch.object(add_final_score, 'OUTPUT_DIR', root / 'output'), patch.object(
add_final_score, 'process_images'
) as process:
self.assertEqual(add_final_score.main([str(root), '--copy-merged-histogramme']), 0)
process.assert_called_once_with(root, root / 'output' / root.name, copy_merged_histogramme=True)
def test_creates_complete_student_directory(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
+100
View File
@@ -0,0 +1,100 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import pymupdf
from PIL import Image
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, configuration
from copienator.commands import annotating, giving_names, reading_annotations as standard
from copienator.commands import reading_grouped_annotations as grouped
from copienator.annotation_modifications import BASELINE, rebuild
from copienator.filesystem import staged_files
from copienator.json_io import read_json
class BnotPdfReturnTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.workspace = EvaluationWorkspace(self.root)
self.answer = self.root / 'Copies/Copie01/Ex 1.pdf'
self.answer.parent.mkdir(parents=True)
self.answer.touch()
(self.root / 'labels').write_text('Ex 1\nEx 2\n')
atomic_write_json(self.root / 'Copies/Copie01.json', {'name': 'Student'})
(self.root / 'names').write_text('Student\n')
self.output = self.root / 'Bnot/Copie01'
self.output.mkdir(parents=True)
atomic_write_json(self.output / 'bnote.json', {'images': [
{'id': '01', 'label': 'Ex 1', 'header_height': 0, 'hmin': 0, 'hmax': 80},
]})
self.data = {'01': {'Ex 1': {'pdf_path': self.answer, 'coordinates': (0, 0),
'result': {'score': 2, 'error': '', 'feedback': []}}}}
for mode in ('Text2', 'Sol2'):
path = self.root / mode / 'Ex 1.pdf'
path.parent.mkdir()
path.touch()
self.rendered = []
def render(self, path):
self.rendered.append(Path(path))
color = {'Text2': 'red', 'Sol2': 'blue'}.get(Path(path).parent.name, 'yellow')
return Image.new('RGB', (100, 60), color), 100, 60
def read(self, grouped_mode=False, score=2):
self.data['01']['Ex 1']['result']['score'] = score
with (
patch.object(annotating, 'make_base_image', side_effect=self.render),
patch.object(annotating, 'compose_label_image', return_value=(Image.new('RGB', (100, 80), 'yellow'), 0)),
):
if grouped_mode:
result, _ = grouped.apply_actions_and_regenerate_grouped(
self.workspace, self.data, '01', [], {}, ['Ex 1', 'Ex 2'], annotation_dir='Bnot')
else:
result = standard.apply_actions_and_regenerate(
self.workspace, self.data, '01', [], None, ['Ex 1', 'Ex 2'])
self.assertEqual(result, ExitCode.SUCCESS)
def check_pdf(self):
pdf = self.output / 'Concat_F.pdf'
with pymupdf.open(pdf) as document:
self.assertGreater(len(document), 0)
pixmap = document[0].get_pixmap(matrix=pymupdf.Matrix(100 / 72, 100 / 72))
image = Image.frombytes('RGB', (pixmap.width, pixmap.height), pixmap.samples)
# Supplements precede the answer, with the existing page margins.
for y, channel in ((35, 0), (95, 2)):
pixel = image.getpixel((60, y))
self.assertGreater(pixel[channel], 180)
self.assertLess(pixel[1], 70)
self.assertTrue((self.output / 'Concat_F.jpg').is_file())
self.assertIn(self.root / 'Text2/Ex 1.pdf', self.rendered)
self.assertIn(self.root / 'Sol2/Ex 1.pdf', self.rendered)
return pdf
def test_reader_creates_pdf_and_naming_publishes_it(self):
self.read()
pdf = self.check_pdf()
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=False, RETURN_PDF_ENABLED=True):
self.assertEqual(giving_names.run(self.workspace, annotation_dir='Bnot'), ExitCode.SUCCESS)
named = self.root / 'A Rendre/Student (01)/Student.pdf'
self.assertEqual(named.read_bytes(), pdf.read_bytes())
# Recompilation with no filtered answers must remove the old PDF too.
self.read(score=4)
self.assertFalse(pdf.exists())
self.assertFalse((self.output / 'Concat_F.jpg').exists())
def test_grouped_redo_into_bnot_also_creates_pdf(self):
self.read(grouped_mode=True)
self.check_pdf()
def test_modification_rebuild_keeps_pdf_and_supplements(self):
self.read()
meta = read_json(self.output / BASELINE)
scores = read_json(self.output / 'score.json')
with patch.object(annotating, 'make_base_image', side_effect=self.render):
with staged_files(self.output) as staging:
rebuild(self.root, 'Bnot', self.output, staging, meta, [], scores)
self.check_pdf()
+3
View File
@@ -16,6 +16,9 @@ class ConfirmedLabelRegradingTests(unittest.TestCase):
self.root = Path(temporary.name)
self.workspace = EvaluationWorkspace(self.root)
(self.root / 'labels').write_text('Ex 1\nEx 2\n')
for mode in ('Text', 'Sol'):
(self.root / mode).mkdir()
(self.root / mode / 'Ex 1').write_text(f'{mode} material')
self.group = self.root / 'Par label/Ex 1/Group_1.jpg'
self.group.parent.mkdir(parents=True)
self.group.touch()
+150
View File
@@ -0,0 +1,150 @@
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())
+43 -3
View File
@@ -71,14 +71,19 @@ class GuiConvenienceTests(unittest.TestCase):
self.app.current_step = step
self.app._render_step()
self.assertEqual([button.cget("text") for button in self.app.review_buttons],
["gestion_classe re", "gestion_classe wsn", "gestion_classe we"])
["gestion_classe re", "gestion_classe wsn", "gestion_classe we", "gestion_classe mei", "gestion_classe wsnm"])
self.assertEqual(self.app.command_box.winfo_manager(), "")
self.assertEqual(self.app.run_button.cget("text"), "Marquer réussie")
self.assertEqual(self.app.arg_vars["evaluation_name"].get(), self.evaluation.name)
self.app.arg_vars["evaluation_name"].set("DS02VB custom")
with patch.object(self.app.runner, "start") as start, \
patch.object(self.app, "_move_selection_from") as advance:
for button, command in zip(self.app.review_buttons, ("re", "wsn", "we")):
for button, command in zip(self.app.review_buttons, ("re", "wsn", "we", "mei", "wsnm")):
button.invoke()
self.assertEqual(start.call_args.args[0], ["gestion_classe", command])
expected = ["gestion_classe", command]
if command not in {"mei", "wsnm"}:
expected.append("DS02VB custom")
self.assertEqual(start.call_args.args[0], expected)
self.app._finish_process(0, False)
self.assertNotEqual(self.app.state_store.step(step.id)["status"], "success")
advance.assert_not_called()
@@ -87,6 +92,41 @@ class GuiConvenienceTests(unittest.TestCase):
start.assert_not_called()
self.assertEqual(self.app.state_store.step(step.id)["status"], "success")
def test_merged_histogram_default_tracks_first_wsnm_launch_and_remains_editable(self):
from copienator_gui.workflow import build_workflow
from copienator_gui.state import StateStore
steps = {step.id: step for step in build_workflow(True)}
self.app.step_by_id.update(steps)
self.app.current_step = steps['final_score']
self.app._render_step()
self.assertFalse(self.app.arg_vars['copy_merged_histogramme'].get())
self.app._save_current_form()
def launch():
self.app.current_step = steps['personal_read']
self.app._render_step()
with patch.object(self.app.runner, 'start') as start:
self.app.review_buttons[4].invoke()
self.assertEqual(start.call_args.args[0], ['gestion_classe', 'wsnm'])
self.app._finish_process(0, False)
launch()
reloaded = StateStore()
reloaded.load(self.evaluation)
self.assertTrue(reloaded.step('personal_read')['wsnm_run'])
self.assertTrue(reloaded.step('final_score')['values']['copy_merged_histogramme'])
self.app.current_step = steps['final_score']
self.app._render_step()
self.assertTrue(self.app.arg_vars['copy_merged_histogramme'].get())
self.assertIn('--copy-merged-histogramme', self.app._make_command())
self.app.arg_vars['copy_merged_histogramme'].set(False)
self.app._save_current_form()
launch()
self.app.current_step = steps['final_score']
self.app._render_step()
self.assertFalse(self.app.arg_vars['copy_merged_histogramme'].get())
self.assertNotIn('--copy-merged-histogramme', self.app._make_command())
def test_annotation_import_cleanup_can_be_unchecked(self):
self.app.tree.selection_set("annotation")
self.app.update()
+4
View File
@@ -24,6 +24,10 @@ class PromptingTests(unittest.TestCase):
def test_live_prompt_includes_only_metadata_ids_in_order(self) -> None:
with tempfile.TemporaryDirectory() as directory:
for mode in ("Text", "Sol"):
folder = Path(directory) / mode
folder.mkdir()
(folder / "I)").write_text(f"{mode} material")
image = Path(directory) / "Group_16.jpg"
image.write_bytes(b"image")
image.with_suffix(".json").write_text(