Bnot fixes ; Missing énoncés fixes.
This commit is contained in:
1 parent
f39868207d
commit
55d5cf16c9
18 files changed
+486
-44
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user