from __future__ import annotations import importlib import io import json import os import queue import sys import tempfile import time import unittest from concurrent.futures import ThreadPoolExecutor from contextlib import redirect_stderr, redirect_stdout from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, patch from PIL import Image from pypdf import PdfReader, PdfWriter from copienator import ( EvaluationWorkspace, WorkspaceNotFoundError, WorkspaceValidationError, atomic_update_json, atomic_write_bytes, atomic_write_json, read_json, workspace_from_target, ) from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides from copienator.annotation_data import AnnotationLoadResult, load_annotation_data from copienator.filesystem import staged_directory, staged_files from copienator.dispatcher import COMMANDS, main as dispatcher_main from copienator_gui.app import ( CopienatorApp, DEFAULT_HTTPS_PROXY, build_runner_environment, copy_pdf_paths, detected_annotation_directories, get_personal_interro_files, has_manual_conflicts, plotting_shortcut_lines, process_status, ) from copienator_gui.giving_names import ( find_name_issues, original_copy_path, rename_return_copy, ) from copienator_gui.diagnostics import collect_diagnostics from copienator_gui.runner import ProcessRunner from copienator_gui.state import StateStore from copienator_gui import workflow as workflow_module from copienator_gui.workflow import build_command, build_workflow, evaluation_argument from copienator.commands.copies_tools import rename_all, rotate_all from copienator.platform import ( WindowsLabelError, replace_with_link_or_copy, safe_filename, validate_windows_labels, windows_filename_problems, ) REPOSITORY = Path(__file__).resolve().parents[1] def load_script_module(filename: str, _module_name: str): stem = Path(filename).stem.replace("-", "_") if stem == "import": stem = "import_annotations" return importlib.import_module(f"copienator.commands.{stem}") def command_arguments(command: list[str]) -> list[str]: assert command[2:4] == ["-m", "copienator"] return command[5:] class WorkspaceTests(unittest.TestCase): def test_canonical_paths_and_no_constructor_side_effects(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) / "Exam" root.mkdir() workspace = EvaluationWorkspace(root, Path(directory)) self.assertFalse(workspace.metadata_dir.exists()) self.assertEqual(workspace.labels_file, root / "labels") self.assertEqual(workspace.label_groups_file, root / "label_groups") self.assertEqual( workspace.gemini_exam_items_file, root / "Tmp" / "exam_items.txt" ) self.assertEqual(workspace.copies_dir, root / "Copies") self.assertEqual(workspace.annotation_dir("grouped"), root / "BGnot") self.assertEqual(workspace.state_database, root / ".copienator" / "state.sqlite3") self.assertEqual(workspace.command_argument(), "Exam") def test_discover_from_nested_artifact(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) / "Exam" nested = root / "Copies" / "Copie01" nested.mkdir(parents=True) (root / "labels").write_text("Ex 1\n", encoding="utf-8") artifact = nested / "Ex 1.pdf" artifact.touch() workspace = EvaluationWorkspace.discover(artifact) self.assertEqual(workspace.root, root) def test_discover_and_require_report_clear_errors(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) with self.assertRaises(WorkspaceNotFoundError): EvaluationWorkspace.discover(root) workspace = EvaluationWorkspace(root) with self.assertRaises(WorkspaceValidationError) as context: workspace.require("enonce.pdf", "labels") self.assertEqual(context.exception.missing, ["enonce.pdf", "labels"]) (root / "Copies").write_text("not a directory", encoding="utf-8") with self.assertRaises(WorkspaceValidationError): workspace.require_directories("Copies") def test_names_file_prefers_evaluation_then_repository(self) -> None: with tempfile.TemporaryDirectory() as directory: repository = Path(directory) root = repository / "Exam" root.mkdir() (repository / "names").write_text("Global\n", encoding="utf-8") workspace = EvaluationWorkspace(root, repository) self.assertEqual(workspace.names_file(), repository / "names") (root / "names").write_text("Local\n", encoding="utf-8") self.assertEqual(workspace.names_file(), root / "names") class DispatcherTests(unittest.TestCase): def test_unknown_command_has_argument_error_code(self) -> None: with redirect_stderr(io.StringIO()): self.assertEqual(dispatcher_main(["does-not-exist"]), 2) def test_every_gui_python_command_is_registered(self) -> None: for step in build_workflow(True): for variant in step.variants: if variant.kind == "python": with self.subTest(step=step.id, command=variant.program): self.assertIn(variant.program, COMMANDS) def test_root_contains_only_configuration_python_modules(self) -> None: root_modules = {path.name for path in REPOSITORY.glob("*.py")} self.assertIn("default_config.py", root_modules) self.assertLessEqual(root_modules, {"config.py", "default_config.py"}) class AtomicJsonTests(unittest.TestCase): def test_atomic_binary_round_trip(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "result.jsonl" atomic_write_bytes(path, b'{"one":1}\n') self.assertEqual(path.read_bytes(), b'{"one":1}\n') def test_atomic_round_trip_and_unicode(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "nested" / "state.json" value = {"name": "Élève", "steps": [1, 2]} atomic_write_json(path, value) self.assertEqual(read_json(path), value) self.assertEqual(list(path.parent.glob(f".{path.name}.*.tmp")), []) def test_serialization_failure_preserves_previous_file(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "state.json" atomic_write_json(path, {"version": 1}) before = path.read_bytes() with self.assertRaises(TypeError): atomic_write_json(path, {"invalid": object()}) # type: ignore[dict-item] self.assertEqual(path.read_bytes(), before) def test_locked_updates_do_not_lose_concurrent_changes(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "state.json" def increment() -> None: def update(value): value["count"] += 1 atomic_update_json( path, update, default_factory=lambda: {"count": 0}, ) with ThreadPoolExecutor(max_workers=8) as executor: list(executor.map(lambda _index: increment(), range(40))) self.assertEqual(read_json(path), {"count": 40}) def test_gui_state_uses_workspace_and_atomic_writer(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) store = StateStore() store.load(root) store.update_step("labels", status="success") self.assertEqual(store.workspace, EvaluationWorkspace(root)) persisted = read_json(root / ".copienator-gui.json") self.assertEqual(persisted["steps"]["labels"]["status"], "success") def test_invalidation_preserves_first_visit_marker(self) -> None: with tempfile.TemporaryDirectory() as directory: store = StateStore() store.load(Path(directory)) store.update_step("splitting", status="success", visited=True) store.invalidate_after(["plotting", "splitting"], "plotting") self.assertEqual(store.step("splitting")["status"], "stale") self.assertTrue(store.step("splitting")["visited"]) class AnnotationDataTests(unittest.TestCase): def test_loader_indexes_coordinates_without_mutating_correction(self) -> None: with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_dir = evaluation / "Copies" / "Copie01" group_dir = evaluation / "Par label" / "Ex 1" copy_dir.mkdir(parents=True) group_dir.mkdir(parents=True) (copy_dir / "Ex 1_new.pdf").write_bytes(b"pdf") correction = { "Ex 1": [ [ { "id": "01", "result": { "suffix": "_new", "feedback": [{"text": "x", "box_2d": [100, 200, 300, 400]}], }, } ] ] } atomic_write_json(evaluation / "correction.json", correction) atomic_write_json( group_dir / "Group_1.json", [["01", 10, 90, 1.0, "Ex 1"]], ) Image.new("RGB", (100, 200), "white").save(group_dir / "Group_1.jpg") loaded = load_annotation_data(EvaluationWorkspace(evaluation)) item = loaded.data["01"]["Ex 1"] self.assertEqual(item["pdf_path"], copy_dir / "Ex 1_new.pdf") self.assertEqual(item["coordinates"], (10, 90)) self.assertEqual(item["result"]["feedback"][0]["box_2d"], [20, 20, 60, 40]) self.assertEqual(read_json(evaluation / "correction.json"), correction) self.assertEqual(loaded.warnings, []) def test_refaire_filter_adds_a_missing_correction_entry(self) -> None: with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_dir = evaluation / "Copies" / "Copie01" copy_dir.mkdir(parents=True) (evaluation / "Par label").mkdir() (copy_dir / "Ex 2.pdf").write_bytes(b"pdf") atomic_write_json(evaluation / "correction.json", {}) loaded = load_annotation_data( EvaluationWorkspace(evaluation), refaire_list=[["Copie01", ["Ex 2"]]], ) item = loaded.data["01"]["Ex 2"] self.assertEqual(item["pdf_path"], copy_dir / "Ex 2.pdf") self.assertEqual(item["result"]["error"], "non traité") def test_staged_directory_preserves_then_replaces_destination(self) -> None: with tempfile.TemporaryDirectory() as directory: destination = Path(directory) / "output" destination.mkdir() (destination / "state.txt").write_text("old", encoding="utf-8") with self.assertRaises(RuntimeError), staged_directory( destination ) as staging: (staging / "state.txt").write_text("broken", encoding="utf-8") raise RuntimeError("rendering failed") self.assertEqual( (destination / "state.txt").read_text(encoding="utf-8"), "old" ) with staged_directory(destination) as staging: (staging / "state.txt").write_text("new", encoding="utf-8") self.assertEqual( (destination / "state.txt").read_text(encoding="utf-8"), "new" ) leftovers = [path for path in destination.parent.iterdir() if path.name.startswith(".output.")] self.assertEqual(leftovers, []) def test_staged_files_preserve_inputs_and_roll_back_outputs(self) -> None: with tempfile.TemporaryDirectory() as directory: destination = Path(directory) / "Copie01" destination.mkdir() (destination / "bnote.json").write_text("input", encoding="utf-8") (destination / "score.json").write_text("old", encoding="utf-8") with self.assertRaises(RuntimeError), staged_files(destination) as staging: (staging / "score.json").write_text("broken", encoding="utf-8") (staging / "Concat.jpg").write_text("partial", encoding="utf-8") raise RuntimeError("rendering failed") self.assertEqual((destination / "score.json").read_text(), "old") self.assertFalse((destination / "Concat.jpg").exists()) with staged_files(destination) as staging: (staging / "score.json").write_text("new", encoding="utf-8") (staging / "Concat.jpg").write_text("complete", encoding="utf-8") self.assertEqual((destination / "score.json").read_text(), "new") self.assertEqual((destination / "bnote.json").read_text(), "input") def test_annotation_actions_are_shared_and_do_not_touch_json_sources(self) -> None: with tempfile.TemporaryDirectory() as directory: score_path = Path(directory) / "score.json" atomic_write_json(score_path, {"Ex 1": "3"}) labels_data = { "Ex 1": { "result": { "score": 1, "feedback": [{"text": "global"}], } } } logs = [] dirty = apply_checkbox_actions( labels_data, [{"label": "Ex 1", "type": "del_global", "index": 0}], logs.append, ) dirty |= apply_score_overrides(labels_data, score_path, logs.append) self.assertEqual(dirty, {"Ex 1"}) self.assertTrue( labels_data["Ex 1"]["result"]["feedback"][0]["to_delete"] ) self.assertEqual(labels_data["Ex 1"]["result"]["score"], "3") self.assertEqual(read_json(score_path), {"Ex 1": "3"}) class StandardCliTests(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.modules = { "annotating": load_script_module("annotating.py", "annotating"), "annotating_with_checks": load_script_module( "annotating_with_checks.py", "annotating_with_checks" ), "annotating_by_label": load_script_module( "annotating_by_label.py", "annotating_by_label" ), "reading_annotations": load_script_module( "reading_annotations.py", "reading_annotations" ), "reading_grouped_annotations": load_script_module( "reading_grouped_annotations.py", "reading_grouped_annotations" ), "cutleft": load_script_module("cutleft.py", "cutleft"), "splitting_int": load_script_module( "splitting_int.py", "splitting_int" ), "page_splitter": load_script_module( "page_splitter.py", "page_splitter" ), "plotting": load_script_module("plotting.py", "plotting"), "gemini_for_labels": load_script_module( "gemini_for_labels.py", "gemini_for_labels" ), "gemini_for_enonce": load_script_module( "gemini_for_enonce.py", "gemini_for_enonce" ), "enonce_info": load_script_module("enonce_info.py", "enonce_info"), "correction": load_script_module("correction.py", "correction"), "submit_batches": load_script_module( "submit_batches.py", "submit_batches" ), "batch_status": load_script_module("batch_status.py", "batch_status"), "fetch_batched_results": load_script_module( "fetch_batched_results.py", "fetch_batched_results" ), "copies_tools": load_script_module( "copies_tools.py", "copienator_copies_tools_test" ), "export": load_script_module("export.py", "copienator_export_test"), "import": load_script_module("import.py", "copienator_import_test"), "giving_names": load_script_module( "giving_names.py", "copienator_giving_names_test" ), "grouping": load_script_module("grouping.py", "copienator_grouping_test"), "post_correction": load_script_module( "post-correction.py", "copienator_post_correction_test" ), "resolve_manual": load_script_module( "resolve_manual.py", "copienator_resolve_manual_test" ), "verify_groups": load_script_module( "verify_groups.py", "copienator_verify_groups_test" ), } def test_missing_evaluation_has_standard_exit_code(self) -> None: with tempfile.TemporaryDirectory() as directory: missing = str(Path(directory) / "missing") invocations = { "export": [missing], "import": [missing], "giving_names": [missing, "BGnot"], "grouping": [missing], "post_correction": [missing], "resolve_manual": [missing], "verify_groups": [missing], "copies_tools": ["rotate", missing], "annotating": [missing], "annotating_with_checks": [missing], "annotating_by_label": [missing], "reading_annotations": [missing], "reading_grouped_annotations": [missing], "cutleft": [missing], "splitting_int": [missing], "page_splitter": [missing], "plotting": [missing], "gemini_for_labels": [missing], "gemini_for_enonce": [missing], "enonce_info": [missing], "correction": [missing], "submit_batches": [missing], "fetch_batched_results": [missing], } for name, arguments in invocations.items(): with self.subTest(script=name), redirect_stderr(io.StringIO()): self.assertEqual(self.modules[name].main(arguments), 3) def test_invalid_arguments_use_argparse_exit_code(self) -> None: with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as context: self.modules["grouping"].main([]) self.assertEqual(context.exception.code, 2) def test_unexpected_processing_error_has_failure_exit_code(self) -> None: module = self.modules["grouping"] with tempfile.TemporaryDirectory() as directory: with patch.object( module, "run", side_effect=RuntimeError("broken") ), redirect_stderr(io.StringIO()) as errors: self.assertEqual(module.main([directory]), 1) self.assertIn("broken", errors.getvalue()) def test_prerequisites_are_checked_before_creating_outputs(self) -> None: module = self.modules["export"] with tempfile.TemporaryDirectory() as directory: base = Path(directory) evaluation = base / "Exam" evaluation.mkdir() export_dir = base / "Export" with patch.object(module, "EXPORT_DIR", export_dir), redirect_stderr( io.StringIO() ): self.assertEqual(module.main([str(evaluation)]), 3) self.assertFalse(export_dir.exists()) def test_gui_understands_standard_process_exit_codes(self) -> None: self.assertEqual(process_status(0), "success") self.assertEqual(process_status(1), "failed") self.assertEqual(process_status(3), "failed") self.assertEqual(process_status(4), "partial") self.assertEqual(process_status(130), "interrupted") self.assertEqual(process_status(0, interrupted=True), "interrupted") def test_gui_commands_are_accepted_by_script_parsers(self) -> None: steps = {step.id: step for step in build_workflow(True)} evaluation = "Evaluation with spaces" cases = { "gemini_for_enonce": ( "statement", "gemini", {"target": evaluation, "restart": True}, ), "enonce_info": ( "statement", "personal", {"target": evaluation}, ), "export": ( "export", "default", {"target": evaluation, "annotation_dir": "Bnot", "refaire": True}, ), "import": ( "import", "default", {"target": evaluation, "annotation_dir": "Anot", "refaire": True}, ), "giving_names": ( "giving_names", "default", {"target": evaluation, "annotation_dir": "BGnot", "update": True}, ), "grouping": ("grouping", "default", {"target": evaluation}), "post_correction": ( "post_correction", "default", {"target": evaluation}, ), "resolve_manual": ( "manual_resolution", "default", {"target": evaluation}, ), "annotating": ( "annotation", "simple", {"target": evaluation, "overwrite": True}, ), "annotating_with_checks": ( "annotation", "checks", {"target": evaluation, "overwrite": True, "refaire": True}, ), "annotating_by_label": ( "annotation", "grouped", {"target": evaluation, "overwrite": True}, ), "reading_annotations": ( "read_annotations", "standard", {"target": evaluation, "update_score": True}, ), "reading_grouped_annotations": ( "read_annotations", "grouped", {"target": evaluation, "update_score": True, "refaire": True}, ), "cutleft": ( "cutleft", "default", {"target": evaluation, "fullpage": True}, ), "splitting_int": ( "splitting", "default", {"target": evaluation}, ), "page_splitter": ( "page_splitter", "default", {"target": evaluation}, ), "plotting": ( "plotting", "default", {"target": evaluation}, ), "gemini_for_labels": ( "labels", "default", {"target": evaluation, "overwrite": True}, ), "correction": ( "correction", "live", {"target": evaluation, "overwrite": True, "limit": 5}, ), "submit_batches": ( "submit_batches", "default", {"target": evaluation}, ), "fetch_batched_results": ( "fetch_batches", "default", {"target": evaluation}, ), } for module_name, (step_id, variant_id, values) in cases.items(): step = steps[step_id] variant = next(item for item in step.variants if item.id == variant_id) command = build_command(REPOSITORY, step, variant, values, evaluation) with self.subTest(script=module_name): parsed = self.modules[module_name].build_parser().parse_args( command_arguments(command) ) parsed_path = getattr(parsed, "evaluation", None) or parsed.target self.assertEqual(str(parsed_path), evaluation) for step_id in ("rotate", "rename"): step = steps[step_id] variant = step.variants[0] command = build_command( REPOSITORY, step, variant, {"target": evaluation}, evaluation, ) with self.subTest(script=f"copies_tools:{step_id}"): parsed = self.modules["copies_tools"].build_parser().parse_args( command_arguments(command) ) self.assertEqual(parsed.operation, step_id) self.assertEqual(str(parsed.evaluation), evaluation) def test_enonce_info_preserves_labels_when_no_blocks_are_found(self) -> None: module = self.modules["enonce_info"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" evaluation.mkdir() (evaluation / "source.tex").write_text( "No SHEETINFO blocks\n", encoding="utf-8" ) (evaluation / "labels").write_text("Existing\n", encoding="utf-8") self.assertEqual( module.process_directory(EvaluationWorkspace(evaluation)), 4 ) self.assertEqual((evaluation / "labels").read_text(), "Existing\n") self.assertFalse(list(evaluation.glob(".labels.*.tmp"))) def test_export_main_copies_outputs(self) -> None: module = self.modules["export"] with tempfile.TemporaryDirectory() as directory: base = Path(directory) evaluation = base / "Exam" source = evaluation / "BGnot" / "Ex 1" source.mkdir(parents=True) (source / "Concat.pdf").write_bytes(b"annotated") with patch.object(module, "EXPORT_DIR", base / "Export"): self.assertEqual(module.main([str(evaluation)]), 0) exported = base / "Export" / "Exam" / "Ex 1.pdf" self.assertEqual(exported.read_bytes(), b"annotated") def test_export_accepts_each_annotation_directory(self) -> None: module = self.modules["export"] for annotation_dir in ("Anot", "Bnot"): with self.subTest(annotation_dir=annotation_dir), tempfile.TemporaryDirectory() as directory: base = Path(directory) evaluation = base / "Exam" source = evaluation / annotation_dir / "Copie01" source.mkdir(parents=True) suffix = ".jpg" if annotation_dir == "Anot" else ".pdf" (source / f"Concat{suffix}").write_bytes(annotation_dir.encode()) with patch.object(module, "EXPORT_DIR", base / "Export"): self.assertEqual(module.main([str(evaluation), annotation_dir]), 0) self.assertEqual( (base / "Export" / "Exam" / f"Copie01{suffix}").read_bytes(), annotation_dir.encode(), ) def test_import_main_copies_handwritten_annotations(self) -> None: module = self.modules["import"] with tempfile.TemporaryDirectory() as directory: base = Path(directory) evaluation = base / "Exam" target = evaluation / "BGnot" / "Ex 1" target.mkdir(parents=True) import_dir = base / "Import" import_dir.mkdir() (import_dir / "Ex 1.pdf").write_bytes(b"handwritten") with patch.object(module, "IMPORT_DIR", import_dir): self.assertEqual(module.main([str(evaluation)]), 0) self.assertEqual( (target / "Concat_annotated.pdf").read_bytes(), b"handwritten" ) def test_import_accepts_each_annotation_directory(self) -> None: module = self.modules["import"] for annotation_dir in ("Anot", "Bnot"): with self.subTest(annotation_dir=annotation_dir), tempfile.TemporaryDirectory() as directory: base = Path(directory) evaluation = base / "Exam" target = evaluation / annotation_dir / "Copie01" target.mkdir(parents=True) import_dir = base / "Import" import_dir.mkdir() suffix = ".jpg" if annotation_dir == "Anot" else ".pdf" (import_dir / f"Copie01{suffix}").write_bytes(b"handwritten") with patch.object(module, "IMPORT_DIR", import_dir): self.assertEqual(module.main([str(evaluation), annotation_dir]), 0) self.assertEqual( (target / f"Concat_annotated{suffix}").read_bytes(), b"handwritten" ) def test_giving_names_main_builds_return_directory(self) -> None: module = self.modules["giving_names"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copies = evaluation / "Copies" annotations = evaluation / "BGnot" / "Copie01" copies.mkdir(parents=True) annotations.mkdir(parents=True) atomic_write_json(copies / "Copie01.json", {"name": "Élève Test"}) atomic_write_json(annotations / "score.json", {"total": 10}) atomic_write_json(annotations / "info.json", { "total": {"present": False, "not_empty": False, "touched": False, "score": 10} }) (evaluation / "labels").write_text("total\n") (annotations / "Concat.jpg").write_bytes(b"image") (evaluation / "names").write_text("Élève Test\n", encoding="utf-8") self.assertEqual(module.main([str(evaluation), "BGnot"]), 0) destination = evaluation / "A Rendre" / "Élève Test (01)" self.assertEqual((destination / "Élève Test.jpg").read_bytes(), b"image") self.assertEqual(read_json(destination / "score.json"), {"total": 10}) def test_grouping_main_accepts_empty_copies_directory(self) -> None: module = self.modules["grouping"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" (evaluation / "Copies").mkdir(parents=True) self.assertEqual(module.main([str(evaluation)]), 0) self.assertTrue((evaluation / "Par label").is_dir()) def test_cutleft_can_target_one_copy_without_rendering_at_import(self) -> None: module = self.modules["cutleft"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_pdf = evaluation / "Copies" / "Copie01.pdf" copy_pdf.parent.mkdir(parents=True) copy_pdf.write_bytes(b"pdf") with patch.object(module, "ImageReviewer") as reviewer: reviewer.return_value.completed = True reviewer.return_value.had_errors = False self.assertEqual(module.main([str(copy_pdf), "--fullpage"]), 0) files, output_dir = reviewer.call_args.args[:2] self.assertEqual(files, [copy_pdf]) self.assertEqual(output_dir, evaluation / "Cutleft") self.assertEqual(reviewer.call_args.kwargs["default_max_per_file"], 1) def test_cutleft_atomic_save_removes_only_obsolete_copy_outputs(self) -> None: module = self.modules["cutleft"] with tempfile.TemporaryDirectory() as directory: output_dir = Path(directory) / "Cutleft" output_dir.mkdir() (output_dir / "Copie01_01.jpg").write_bytes(b"old-one") (output_dir / "Copie01_02.jpg").write_bytes(b"old-two") (output_dir / "Copie02_01.jpg").write_bytes(b"other-copy") (output_dir / "Copie01_schema.json").write_text( "{}", encoding="utf-8" ) image = Image.new("RGB", (5, 5), "white") result = ( image, [image], { "original_filename": "Copie01.pdf", "total_pages": 1, "number_of_files": 1, "columns_per_file": [1], }, ) module.save_results(result, Path("Copie01.pdf"), output_dir) self.assertTrue((output_dir / "Copie01_01.jpg").is_file()) self.assertFalse((output_dir / "Copie01_02.jpg").exists()) self.assertEqual( (output_dir / "Copie02_01.jpg").read_bytes(), b"other-copy" ) self.assertEqual( read_json(output_dir / "Copie01_schema.json")["total_pages"], 1 ) def test_splitting_missing_json_is_partial(self) -> None: module = self.modules["splitting_int"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_pdf = evaluation / "Copies" / "Copie01.pdf" copy_pdf.parent.mkdir(parents=True) copy_pdf.write_bytes(b"pdf") (evaluation / "labels").write_text("Ex 1\n", encoding="utf-8") self.assertEqual(module.main([str(evaluation)]), 4) def test_splitting_failure_preserves_previous_copy_outputs(self) -> None: module = self.modules["splitting_int"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_pdf = evaluation / "Copies" / "Copie01.pdf" output_dir = evaluation / "Copies" / "Copie01" output_dir.mkdir(parents=True) copy_pdf.write_bytes(b"pdf") (output_dir / "sentinel.pdf").write_bytes(b"old") workspace = EvaluationWorkspace(evaluation) with patch.object( module, "_render_split_outputs", side_effect=RuntimeError("render failed"), ), self.assertRaises(RuntimeError): module.split_an_interro(workspace, copy_pdf, []) self.assertEqual((output_dir / "sentinel.pdf").read_bytes(), b"old") def test_splitting_moves_obsolete_outputs_to_missing_on_commit(self) -> None: module = self.modules["splitting_int"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_pdf = evaluation / "Copies" / "Copie01.pdf" output_dir = evaluation / "Copies" / "Copie01" missing_dir = output_dir / "Missing" missing_dir.mkdir(parents=True) copy_pdf.write_bytes(b"pdf") (output_dir / "Old.pdf").write_bytes(b"obsolete") (missing_dir / "Earlier.pdf").write_bytes(b"earlier") def render(_pdf, _coordinates, staging): (staging / "Ex 1.pdf").write_bytes(b"new") return {"Ex 1.pdf"} with patch.object(module, "_render_split_outputs", side_effect=render): module.split_an_interro(EvaluationWorkspace(evaluation), copy_pdf, []) self.assertEqual((output_dir / "Ex 1.pdf").read_bytes(), b"new") self.assertEqual( (output_dir / "Missing" / "Old.pdf").read_bytes(), b"obsolete" ) self.assertEqual( (output_dir / "Missing" / "Earlier.pdf").read_bytes(), b"earlier" ) def test_splitting_renders_a_real_one_page_answer(self) -> None: module = self.modules["splitting_int"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_pdf = evaluation / "Copies" / "Copie01.pdf" copy_pdf.parent.mkdir(parents=True) document = module.pymupdf.open() page = document.new_page(width=600, height=800) page.insert_text((100, 200), "Student answer") document.save(copy_pdf) document.close() (evaluation / "labels").write_text("Ex 1\n", encoding="utf-8") atomic_write_json( copy_pdf.with_suffix(".json"), { "name": "Copie01", "list": [{"label": "Ex 1", "box_2d": [100, 100, 300, 300]}], }, ) self.assertEqual(module.main([str(evaluation)]), 0) answer = evaluation / "Copies" / "Copie01" / "Ex 1.pdf" self.assertTrue(answer.is_file()) self.assertEqual(len(PdfReader(answer).pages), 1) def test_page_splitter_commits_original_and_generated_copy(self) -> None: module = self.modules["page_splitter"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" evaluation.mkdir() original = evaluation / "Copie01.pdf" generated = evaluation / ".generated.pdf" original.write_bytes(b"original") generated.write_bytes(b"processed") workspace = EvaluationWorkspace(evaluation) output = module.commit_processed_pdf(workspace, original, generated) self.assertEqual(output, evaluation / "Copies" / "Copie01.pdf") self.assertEqual(output.read_bytes(), b"processed") self.assertEqual( (evaluation / "Copies Originales" / "Copie01.pdf").read_bytes(), b"original", ) self.assertFalse(original.exists()) def test_page_splitter_commit_failure_rolls_back_both_files(self) -> None: module = self.modules["page_splitter"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" original = evaluation / "Copie01.pdf" backup = evaluation / "Copies Originales" / "Copie01.pdf" output = evaluation / "Copies" / "Copie01.pdf" backup.parent.mkdir(parents=True) output.parent.mkdir() original.write_bytes(b"new original") backup.write_bytes(b"previous original") output.write_bytes(b"previous output") missing_generated = evaluation / "missing-generated.pdf" with self.assertRaises(FileNotFoundError): module.commit_processed_pdf( EvaluationWorkspace(evaluation), original, missing_generated ) self.assertEqual(original.read_bytes(), b"new original") self.assertEqual(backup.read_bytes(), b"previous original") self.assertEqual(output.read_bytes(), b"previous output") def test_page_splitter_reprocesses_from_preserved_original(self) -> None: module = self.modules["page_splitter"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" generated = evaluation / "Copies" / "Copie01.pdf" original = evaluation / "Copies Originales" / "Copie01.pdf" generated.parent.mkdir(parents=True) original.parent.mkdir() generated.write_bytes(b"processed") original.write_bytes(b"original") workspace = EvaluationWorkspace(evaluation) self.assertEqual( module._selected_inputs(workspace, generated), [original], ) def test_plotting_batch_save_is_atomic(self) -> None: module = self.modules["plotting"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" (evaluation / "Copies").mkdir(parents=True) viewer = module.ImageViewer.__new__(module.ImageViewer) viewer.base_dir = evaluation viewer.active_copie_name = "Copie01" viewer.accumulated_results = { "name": "Test", "list": [{"label": "Ex 1"}], } viewer.save_current_batch() self.assertEqual( read_json(evaluation / "Copies" / "Copie01.json"), {"name": "Test", "list": [{"label": "Ex 1"}]}, ) self.assertIsNone(viewer.accumulated_results) def test_plotting_detects_labels_missing_from_two_thirds_of_copies(self) -> None: module = self.modules["plotting"] with tempfile.TemporaryDirectory() as directory: copies = Path(directory) detected = { "Copie01_01.json": ["Ex 1", "Ex 3"], "Copie02_01.json": ["Ex 1", "Ex 3"], "Copie03_01.json": ["Ex 1", "Ex 2", "Ex 3"], } for filename, labels in detected.items(): atomic_write_json( copies / filename, {"list": [{"label": label} for label in labels]}, ) missing = module.frequently_missing_labels( copies, ["Ex 1", "Ex 2", "Ex 3", "Ex 4"], ) self.assertEqual(missing, {"Ex 2", "Ex 4"}) def test_plotting_mutes_only_a_single_commonly_missing_label(self) -> None: module = self.modules["plotting"] labels = ["Ex 1", "Ex 2", "Ex 3", "Ex 4"] muted, index = module.label_color("Ex 3", labels, 0, {"Ex 2"}) ordinary, _ = module.label_color("Ex 3", labels, 0, set()) two_missing, _ = module.label_color( "Ex 4", labels, 0, {"Ex 2", "Ex 3"} ) self.assertEqual(muted, module.COMMON_MISSING_LABEL_COLOR) self.assertEqual(index, 2) self.assertEqual(ordinary, module.MISSING_LABEL_COLOR) self.assertEqual(two_missing, module.MISSING_LABEL_COLOR) def test_plotting_validation_preserves_manually_edited_order(self) -> None: module = self.modules["plotting"] with tempfile.TemporaryDirectory() as directory: json_path = Path(directory) / "Copie01_01.json" manually_ordered = [ {"box_2d": [100, 600, 130, 700], "label": "Ex 2"}, {"box_2d": [500, 100, 530, 200], "label": "Ex 1"}, ] atomic_write_json( json_path, {"name": "Student", "list": manually_ordered}, ) viewer = module.ImageViewer.__new__(module.ImageViewer) viewer.is_viewing = True viewer.current_json_path = json_path viewer.current_meta = { "schema": {"columns_per_file": [2]}, "part": 1, } viewer.valid_labels = {"Ex 1", "Ex 2"} viewer.active_copie_name = "Copie01" viewer.accumulated_results = {"name": "Student", "list": []} viewer.history = [] viewer.current_pil_image = Mock() viewer.label = Mock() viewer.on_enter(None) self.assertEqual(read_json(json_path)["list"], manually_ordered) self.assertEqual( [item["label"] for item in viewer.accumulated_results["list"]], ["Ex 2", "Ex 1"], ) def test_plotting_previous_restores_state_across_copies(self) -> None: module = self.modules["plotting"] viewer = module.ImageViewer.__new__(module.ImageViewer) previous_image = Mock() previous_json = Path("Copie01_02.json") previous_meta = {"copie": "Copie01"} previous_results = { "name": "Student 1", "list": [{"label": "Ex 1"}], } current_image = Mock() current_json = Path("Copie02_01.json") current_meta = {"copie": "Copie02"} viewer.is_viewing = True viewer.history = [ ( previous_image, previous_json, previous_meta, "Copie01", previous_results, ) ] viewer.forward_stack = [] viewer.current_pil_image = current_image viewer.current_json_path = current_json viewer.current_meta = current_meta viewer.active_copie_name = "Copie02" viewer.accumulated_results = {"name": "Student 2", "list": []} viewer.display_image = Mock() viewer.on_previous(None) self.assertEqual(viewer.active_copie_name, "Copie01") self.assertEqual(viewer.accumulated_results, previous_results) self.assertEqual( viewer.forward_stack, [(current_image, current_json, current_meta)], ) viewer.display_image.assert_called_once_with( previous_image, previous_json, previous_meta ) def test_plotting_validates_plain_and_directional_labels(self) -> None: module = self.modules["plotting"] self.assertEqual( module.normalized_labels( [ {"label": "Ex 1"}, {"label": "|Ex 2"}, {"label": "Ex 3|"}, {"label": "_"}, ] ), ["Ex 1", "Ex 2", "Ex 3"], ) def test_plotting_worker_failure_still_terminates_its_queue(self) -> None: module = self.modules["plotting"] output_queue = queue.Queue() with patch.object( module, "_worker_items", side_effect=RuntimeError("worker failed") ): module.worker_thread(Path("."), [], [], output_queue) image, json_path, metadata = output_queue.get_nowait() self.assertIsNone(image) self.assertIsNone(json_path) self.assertEqual(metadata, {"worker_error": "worker failed"}) def test_label_detection_resolves_copy_and_cutleft_targets(self) -> None: module = self.modules["gemini_for_labels"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_pdf = evaluation / "Copies" / "Copie01.pdf" image_one = evaluation / "Cutleft" / "Copie01_01.jpg" image_two = evaluation / "Cutleft" / "Copie01_02.jpg" copy_pdf.parent.mkdir(parents=True) image_one.parent.mkdir() copy_pdf.write_bytes(b"pdf") image_one.write_bytes(b"image") image_two.write_bytes(b"image") workspace = EvaluationWorkspace(evaluation) images, warnings = module.selected_images(workspace, [copy_pdf]) self.assertEqual(images, [image_one, image_two]) self.assertEqual(warnings, []) images, warnings = module.selected_images(workspace, [image_two]) self.assertEqual(images, [image_two]) self.assertEqual(warnings, []) def test_label_detection_preserves_context_and_writes_atomically(self) -> None: module = self.modules["gemini_for_labels"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copies = evaluation / "Copies" cutleft = evaluation / "Cutleft" copies.mkdir(parents=True) cutleft.mkdir() first = cutleft / "Copie01_01.jpg" second = cutleft / "Copie01_02.jpg" first.write_bytes(b"first image") second.write_bytes(b"second image") atomic_write_json( copies / "Copie01_01.json", { "name": "Student", "list": [{"box_2d": [1, 2, 3, 4], "label": "Ex 1"}], }, ) response = Mock( text=( '{"name":"Continued","list":' '[{"box_2d":[10,20,30,40],"label":"Ex 2"}]}' ) ) client = Mock() client.models.generate_content.return_value = response generated = module.process_copy_group( EvaluationWorkspace(evaluation), "Copie01", [first, second], client=client, labels_text="Ex 1\nEx 2\n", names_text="Student\n", valid_labels={"Ex 1", "Ex 2"}, valid_names={"Student", "Continued", "Unknown"}, overwrite=False, sleep=lambda _seconds: None, target_interval=0, ) self.assertEqual(generated, 1) self.assertEqual(client.models.generate_content.call_count, 1) self.assertEqual( read_json(copies / "Copie01_02.json"), { "name": "Continued", "list": [{"box_2d": [10, 20, 30, 40], "label": "Ex 2"}], }, ) def test_label_detection_writes_boxes_in_column_reading_order(self) -> None: module = self.modules["gemini_for_labels"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copies = evaluation / "Copies" cutleft = evaluation / "Cutleft" copies.mkdir(parents=True) cutleft.mkdir() image = cutleft / "Copie03_02.jpg" image.write_bytes(b"image") atomic_write_json( cutleft / "Copie03_schema.json", {"columns_per_file": [2, 2]}, ) client = Mock() client.models.generate_content.return_value = Mock( text=( '{"name":"Continued","list":[' '{"box_2d":[100,600,130,700],"label":"Ex 2"},' '{"box_2d":[500,100,530,200],"label":"Ex 1"},' '{"box_2d":[300,620,330,720],"label":"Ex 3"}]}' ) ) module.process_copy_group( EvaluationWorkspace(evaluation), "Copie03", [image], client=client, labels_text="Ex 1\nEx 2\nEx 3\n", names_text="Student\n", valid_labels={"Ex 1", "Ex 2", "Ex 3"}, valid_names={"Student", "Unknown", "Continued"}, overwrite=True, sleep=lambda _seconds: None, target_interval=0, ) result = read_json(copies / "Copie03_02.json") self.assertEqual( [item["label"] for item in result["list"]], ["Ex 1", "Ex 2", "Ex 3"], ) def test_label_detection_retries_unknown_labels(self) -> None: module = self.modules["gemini_for_labels"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" (evaluation / "Copies").mkdir(parents=True) text_dir = evaluation / "Text" text_dir.mkdir() (text_dir / "Ex 1").write_text("Short match", encoding="utf-8") closest_text = text_dir / "Ex 1 : 1)" closest_text.write_text("Relevant statement text", encoding="utf-8") image = evaluation / "Cutleft" / "Copie01_01.jpg" image.parent.mkdir() image.write_bytes(b"image") client = Mock() client.models.generate_content.side_effect = [ Mock( text=( '{"name":"Student","list":' '[{"box_2d":[1,2,3,4],"label":"Ex 1 : l)"}]}' ) ), Mock( text=( '{"name":"Student","list":' '[{"box_2d":[1,2,3,4],"label":"Ex 1 : 1)"}]}' ) ), ] sleeps = [] output = io.StringIO() with redirect_stdout(output): module.process_copy_group( EvaluationWorkspace(evaluation), "Copie01", [image], client=client, labels_text="Ex 1 : 1)\n", names_text="Student\n", valid_labels={"Ex 1 : 1)"}, valid_names={"Student", "Unknown", "Continued"}, overwrite=True, sleep=sleeps.append, target_interval=0, ) self.assertEqual(client.models.generate_content.call_count, 2) self.assertIn(10, sleeps) retry_contents = client.models.generate_content.call_args_list[1].kwargs[ "contents" ] retry_prompt = retry_contents[0].parts[1].text self.assertIn('"Ex 1 : l)"', retry_prompt) self.assertIn("CRITICAL RETRY CONSTRAINT: NEVER return", retry_prompt) self.assertIn("Relevant statement text", retry_prompt) self.assertIn("`Ex 1 : 1)`", retry_prompt) self.assertIn( "Retry context for Copie01_01.jpg: Text/Ex 1 : 1)", output.getvalue(), ) self.assertEqual( read_json(evaluation / "Copies" / "Copie01_01.json")["list"][0][ "label" ], "Ex 1 : 1)", ) def test_label_detection_marks_a_thrice_repeated_unknown_label(self) -> None: module = self.modules["gemini_for_labels"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" (evaluation / "Copies").mkdir(parents=True) (evaluation / "Text").mkdir() (evaluation / "Text" / "Ex 10").write_text( "Question 1 text", encoding="utf-8" ) image = evaluation / "Cutleft" / "Copie42_03.jpg" image.parent.mkdir() image.write_bytes(b"image") repeated = Mock( text=( '{"name":"Continued","list":' '[{"box_2d":[1,2,3,4],"label":"Ex 10 : 1)a)"}]}' ) ) client = Mock() client.models.generate_content.side_effect = [ repeated, repeated, repeated, ] output = io.StringIO() with redirect_stdout(output): generated = module.process_copy_group( EvaluationWorkspace(evaluation), "Copie42", [image], client=client, labels_text="Ex 10 : 1)\nEx 10 : 2)\nEx 10 : 3)\n", names_text="Student\n", valid_labels={"Ex 10 : 1)", "Ex 10 : 2)", "Ex 10 : 3)"}, valid_names={"Student", "Unknown", "Continued"}, overwrite=True, sleep=lambda _seconds: None, target_interval=0, ) self.assertEqual(generated, 1) self.assertEqual(client.models.generate_content.call_count, 3) seeds = [ call.kwargs["config"].seed for call in client.models.generate_content.call_args_list ] self.assertEqual(seeds, [0, 0, 1]) result = read_json(evaluation / "Copies" / "Copie42_03.json") self.assertEqual(result["list"][0]["label"], "??Ex 10 : 1)a)") self.assertIn("keeping them with a ?? prefix", output.getvalue()) def test_correction_overwrite_keeps_previous_state_until_a_commit(self) -> None: module = self.modules["correction"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" evaluation.mkdir() correction = evaluation / "correction.json" progress = evaluation / "correction_progress.json" atomic_write_json(correction, {"Ex 1": [[{"id": "01"}]]}) atomic_write_json(progress, [["old.jpg", "Ex 1"]]) args = module.build_parser().parse_args( [str(evaluation), "--overwrite"] ) module.configure_runtime( EvaluationWorkspace(evaluation), [("new.jpg", "Ex 1")], args, api_client=Mock(), ) self.assertEqual(read_json(correction), {"Ex 1": [[{"id": "01"}]]}) self.assertEqual(read_json(progress), [["old.jpg", "Ex 1"]]) self.assertEqual(module.results, {"Ex 1": []}) self.assertEqual(module.tasks_to_process, [("new.jpg", "Ex 1")]) def test_correction_rejects_overwrite_with_refaire_without_writing(self) -> None: module = self.modules["correction"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" evaluation.mkdir() correction = evaluation / "correction.json" original = {"Ex 1": [[{"id": "01", "result": {"feedback": []}}]]} atomic_write_json(correction, original) errors = io.StringIO() with redirect_stderr(errors): status = module.main( [str(evaluation), "--refaire", "--overwrite"] ) self.assertEqual(status, module.ExitCode.INVALID_ARGUMENTS) self.assertIn( "--overwrite cannot be used with --refaire", errors.getvalue() ) self.assertEqual(read_json(correction), original) def test_correction_reserves_unique_group_indices_concurrently(self) -> None: module = self.modules["correction"] with tempfile.TemporaryDirectory() as directory: groups = Path(directory) / "Par label" label_dir = groups / "Ex 8 : 2)" label_dir.mkdir(parents=True) (label_dir / "Group_3.jpg").write_bytes(b"existing") with patch.object(module, "GROUPS_DIR", groups): module.reserved_group_indices.clear() with ThreadPoolExecutor(max_workers=8) as executor: indices = list( executor.map( module.reserve_next_group_idx, ["Ex 8 : 2)"] * 8, ) ) self.assertEqual(sorted(indices), list(range(3, 11))) self.assertEqual(len(indices), len(set(indices))) def test_live_correction_reports_progress_for_generated_groups(self) -> None: module = self.modules["correction"] with tempfile.TemporaryDirectory() as directory: workspace = EvaluationWorkspace(Path(directory)) args = module.build_parser().parse_args( [str(workspace.root), "--overwrite"] ) initial_tasks = [("group-1.jpg", "Ex 1"), ("group-2.jpg", "Ex 2")] module.configure_runtime( workspace, initial_tasks, args, api_client=Mock(), ) processed = [] def process(task, _precomputed=None): processed.append(task[0]) if task[0] == "group-1.jpg": return [("group-3.jpg", "Ex 3", False)] return [] output = io.StringIO() with patch.object( module, "process_single_task", side_effect=process ), patch.object( module, "resolve_delayed_moves", return_value=[] ), redirect_stdout(output): self.assertEqual(module.run_configured(args), 0) self.assertCountEqual( processed, ["group-1.jpg", "group-2.jpg", "group-3.jpg"], ) progress = [ line for line in output.getvalue().splitlines() if line.startswith("[Progression correction]") ] self.assertEqual(progress[0], "[Progression correction] Groupes traités : 0/2") self.assertEqual(progress[-1], "[Progression correction] Groupes traités : 3/3") def test_live_correction_stops_scheduling_and_waits_for_active_task(self) -> None: module = self.modules["correction"] self.addCleanup(module.stop_requested.clear) with tempfile.TemporaryDirectory() as directory: workspace = EvaluationWorkspace(Path(directory)) args = module.build_parser().parse_args( [str(workspace.root), "--overwrite"] ) module.configure_runtime( workspace, [("group-1.jpg", "Ex 1"), ("group-2.jpg", "Ex 2")], args, api_client=Mock(), ) processed = [] def process(task, _precomputed=None): processed.append(task[0]) module.request_graceful_stop() (workspace.root / "saved-result").write_text(task[0]) return [] with patch.object(module, "NB_THREADS", 1), patch.object( module, "process_single_task", side_effect=process ), patch.object( module, "resolve_delayed_moves", return_value=[] ): status = module.run_configured(args) saved_result = (workspace.root / "saved-result").read_text() self.assertEqual(status, module.ExitCode.INTERRUPTED) self.assertEqual(processed, ["group-1.jpg"]) self.assertEqual(saved_result, "group-1.jpg") def test_gemini_call_finishes_inflight_stream_but_does_not_retry(self) -> None: module = self.modules["correction"] self.addCleanup(module.stop_requested.clear) module.stop_requested.clear() api_client = Mock() def stream(**_kwargs): yield SimpleNamespace(text="first") module.stop_requested.set() yield SimpleNamespace(text="-second") api_client.models.generate_content_stream.side_effect = stream with patch.object(module, "client", api_client): self.assertEqual( module.call_gemini_with_retries("model", [], Mock()), "first-second", ) with self.assertRaises(module.CorrectionStopRequested): module.call_gemini_with_retries("model", [], Mock()) self.assertEqual(api_client.models.generate_content_stream.call_count, 1) def test_interrupted_live_correction_resumes_only_remaining_groups(self) -> None: module = self.modules["correction"] self.addCleanup(module.stop_requested.clear) with tempfile.TemporaryDirectory() as directory: workspace = EvaluationWorkspace(Path(directory)) workspace.copies_dir.mkdir() tasks = [] for index, label in enumerate(("Ex 1", "Ex 2"), start=1): group_dir = workspace.groups_dir / label group_dir.mkdir(parents=True) image = group_dir / f"Group_{index}.jpg" image.write_bytes(b"image") atomic_write_json( image.with_suffix(".json"), [["01", 0, 400, 1.0, label]], ) tasks.append((str(image), label)) response = json.dumps( [{"id": "01", "result": {"error": "", "feedback": []}}] ) call_count = 0 def stream(**_kwargs): nonlocal call_count call_count += 1 if call_count == 1: module.stop_requested.set() yield SimpleNamespace(text=response) api_client = Mock() api_client.models.generate_content_stream.side_effect = stream args = module.build_parser().parse_args([str(workspace.root)]) with patch.object(module, "NB_THREADS", 1), patch.object( module.prompting, "generate_request", return_value=([], Mock()) ): module.configure_runtime( workspace, tasks, args, api_client=api_client ) first_status = module.run_configured(args) first_progress = read_json(workspace.correction_progress_file) first_results = read_json(workspace.correction_file) module.configure_runtime( workspace, tasks, args, api_client=api_client ) pending_on_resume = list(module.tasks_to_process) second_status = module.run_configured(args) final_progress = read_json(workspace.correction_progress_file) final_results = read_json(workspace.correction_file) self.assertEqual(first_status, module.ExitCode.INTERRUPTED) self.assertEqual(first_progress, [list(tasks[0])]) self.assertEqual(len(first_results["Ex 1"]), 1) self.assertEqual(first_results["Ex 2"], []) self.assertEqual(pending_on_resume, [tasks[1]]) self.assertEqual(second_status, module.ExitCode.SUCCESS) self.assertEqual(final_progress, [list(tasks[0]), list(tasks[1])]) self.assertEqual(len(final_results["Ex 1"]), 1) self.assertEqual(len(final_results["Ex 2"]), 1) self.assertEqual(call_count, 2) def test_interrupted_label_errors_resume_auxiliary_requests(self) -> None: module = self.modules["correction"] self.addCleanup(module.stop_requested.clear) for error_type in ("wrong-label", "additional-answer"): with self.subTest(error_type=error_type), tempfile.TemporaryDirectory() as directory: workspace = EvaluationWorkspace(Path(directory)) workspace.copies_dir.mkdir() group_dir = workspace.groups_dir / "Ex 1" group_dir.mkdir(parents=True) image = group_dir / "Group_1.jpg" image.write_bytes(b"image") atomic_write_json( image.with_suffix(".json"), [["01", 0, 400, 1.0, "Ex 1"]], ) task = (str(image), "Ex 1") response = json.dumps( [ { "id": "01", "result": {"error": error_type, "feedback": []}, } ] ) primary_call_count = 0 def stream(**_kwargs): nonlocal primary_call_count primary_call_count += 1 module.stop_requested.set() yield SimpleNamespace(text=response) def resolve_error(_pid, _label, result, _pdf_path): self.assertEqual(result["error"], error_type) result["error"] = "" return [] api_client = Mock() api_client.models.generate_content_stream.side_effect = stream args = module.build_parser().parse_args( [str(workspace.root), "--overwrite"] ) with patch.object(module, "NB_THREADS", 1), patch.object( module.prompting, "generate_request", return_value=([], Mock()) ), patch.object( module, "handle_label_errors", side_effect=resolve_error ) as handler: module.configure_runtime( workspace, [task], args, api_client=api_client ) first_status = module.run_configured(args) saved_pending = read_json( workspace.correction_pending_responses_file ) first_progress_exists = ( workspace.correction_progress_file.exists() ) first_correction_exists = workspace.correction_file.exists() module.configure_runtime( workspace, [task], args, api_client=api_client ) second_status = module.run_configured(args) self.assertEqual(first_status, module.ExitCode.INTERRUPTED) self.assertFalse(first_progress_exists) self.assertFalse(first_correction_exists) self.assertEqual( json.loads(saved_pending[str(image)]), json.loads(response) ) self.assertEqual(second_status, module.ExitCode.SUCCESS) self.assertEqual(primary_call_count, 1) handler.assert_called_once() self.assertEqual( read_json(workspace.correction_progress_file), [list(task)] ) corrected = read_json(workspace.correction_file) self.assertEqual(corrected["Ex 1"][0][0]["result"]["error"], "") self.assertEqual( read_json(workspace.correction_pending_responses_file), {} ) def test_correction_reset_restores_old_and_deletes_new_files(self) -> None: module = self.modules["correction"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_dir = evaluation / "Copies" / "Copie01" copy_dir.mkdir(parents=True) atomic_write_json(evaluation / "correction.json", {"old": True}) atomic_write_json(evaluation / "correction_progress.json", ["old"]) atomic_write_json( evaluation / "correction_pending_responses.json", {"group": "response"} ) (copy_dir / "Ex 1.pdf").write_bytes(b"current") (copy_dir / "Ex 1_old.pdf").write_bytes(b"original") (copy_dir / "Ex 2_new.pdf").write_bytes(b"generated") self.assertEqual(module.main([str(evaluation), "--reset"]), 0) self.assertFalse((evaluation / "correction.json").exists()) self.assertFalse((evaluation / "correction_progress.json").exists()) self.assertFalse( (evaluation / "correction_pending_responses.json").exists() ) self.assertEqual((copy_dir / "Ex 1.pdf").read_bytes(), b"original") self.assertFalse((copy_dir / "Ex 1_old.pdf").exists()) self.assertFalse((copy_dir / "Ex 2_new.pdf").exists()) def test_correction_batch_request_files_are_written_atomically(self) -> None: module = self.modules["correction"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" group_dir = evaluation / "Par label" / "Ex 1" (evaluation / "Copies").mkdir(parents=True) group_dir.mkdir(parents=True) (evaluation / "labels").write_text("Ex 1\n", encoding="utf-8") image = group_dir / "Group_1.jpg" image.write_bytes(b"image") atomic_write_json( image.with_suffix(".json"), [["01", 0, 400, 1.0, "Ex 1"]], ) args = module.build_parser().parse_args([str(evaluation), "--batch"]) module.configure_runtime( EvaluationWorkspace(evaluation), [(str(image), "Ex 1")], args, api_client=Mock(), ) with patch.object(module.prompting, "make_prompt", return_value="prompt"): self.assertEqual(module.run_configured(args), 0) lines = (evaluation / "batch_requests_flash.jsonl").read_text().splitlines() self.assertEqual(len(lines), 1) self.assertEqual(json.loads(lines[0])["key"], str(image)) self.assertEqual( (evaluation / "batch_requests_pro.jsonl").read_text(), "" ) def test_batch_helpers_use_mocked_api_and_atomic_combination(self) -> None: submit = self.modules["submit_batches"] fetch = self.modules["fetch_batched_results"] status = self.modules["batch_status"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" evaluation.mkdir() (evaluation / "batch_requests_flash.jsonl").write_text( "{}\n", encoding="utf-8" ) client = Mock() client.files.upload.return_value = SimpleNamespace(name="files/input") client.batches.create.return_value = SimpleNamespace(name="batches/1") self.assertEqual( submit.run(EvaluationWorkspace(evaluation), client=client), 0 ) self.assertEqual(client.batches.create.call_count, 1) manifest = json.loads((evaluation / "batch_jobs.json").read_text()) self.assertEqual(manifest["jobs"]["flash"]["name"], "batches/1") jobs = [ SimpleNamespace( name="batches/1", display_name=f"flash-correction-{evaluation.name}", state=SimpleNamespace(name="JOB_STATE_SUCCEEDED"), dest=SimpleNamespace(file_name="files/flash-result"), ), SimpleNamespace( name="batches/2", display_name=f"pro-correction-{evaluation.name}", state=SimpleNamespace(name="JOB_STATE_SUCCEEDED"), dest=SimpleNamespace(file_name="files/pro-result"), ), ] client.batches.get.return_value = jobs[0] client.files.download.side_effect = [b'{"flash":1}\n', b'{"pro":1}'] self.assertEqual( fetch.run(EvaluationWorkspace(evaluation), client=client), 0 ) self.assertEqual( (evaluation / "batched_correction_result.jsonl").read_bytes(), b'{"flash":1}\n', ) job = SimpleNamespace( state=SimpleNamespace(name="JOB_STATE_SUCCEEDED"), dest=SimpleNamespace(file_name="files/result"), ) client.batches.get.return_value = job client.files.download.side_effect = None client.files.download.return_value = b'{"downloaded":true}\n' output = evaluation / "one-result.jsonl" self.assertEqual( status.download_job("batches/1", output=output, client=client), 0 ) self.assertEqual(output.read_bytes(), b'{"downloaded":true}\n') def test_post_correction_main_cleans_json_atomically(self) -> None: module = self.modules["post_correction"] cleaned_text = module.clean_string( r"outside_name already\_escaped; " r"bad $\\mathbb{U}_n = \\{z \\in \\mathbb{C}\\}$; " r"valid $\begin{cases} A=0 \\ B=1 \end{cases}$", {}, ) self.assertEqual( cleaned_text, r"outside\_name already\_escaped; " r"bad $\mathbb{U}_n = \{z \in \mathbb{C}\}$; " r"valid $\begin{cases} A=0 \\ B=1 \end{cases}$", ) self.assertEqual(module.clean_string(cleaned_text, {}), cleaned_text) corrupted_json_escapes = ( "Formulas $" + "\x0c" + "rac{1}{2}$, $" + "\t" + "heta " + "\x0c" + "euille 0 [2" + "\t" + "extbackslash pi]$, $e " + "\t" + "imes x$, and [" + "\n" + "egthinspace[0,n]" ) self.assertEqual( module.clean_string(corrupted_json_escapes, {}), r"Formulas $\frac{1}{2}$, $\theta \equiv 0 [2\pi]$, " r"$e \times x$, and [\negthinspace[0,n]", ) self.assertEqual( module.clean_string("x ∈ A and B ⊂ C", {}), r"x \ensuremath{\in} A and B \ensuremath{\subset} C", ) with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" evaluation.mkdir() atomic_write_json( evaluation / "correction.json", {"text": "outside_name and $math_name$", "suffix": "_new"}, ) original = (evaluation / "correction.json").read_bytes() backup = evaluation / "correction_precleanup.json" backup.write_bytes(b"previous backup") self.assertEqual(module.main([str(evaluation)]), 0) self.assertEqual(backup.read_bytes(), original) self.assertEqual( read_json(evaluation / "correction.json"), {"text": r"outside\_name and $math_name$", "suffix": "_new"}, ) cleaned = (evaluation / "correction.json").read_bytes() with patch.object(module, "atomic_write_bytes", side_effect=OSError("backup failed")): self.assertNotEqual(module.main([str(evaluation)]), 0) self.assertEqual((evaluation / "correction.json").read_bytes(), cleaned) self.assertEqual(backup.read_bytes(), original) def test_manual_resolution_with_no_actions_is_safe(self) -> None: module = self.modules["resolve_manual"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" (evaluation / "Copies").mkdir(parents=True) atomic_write_json(evaluation / "correction.json", {}) manual = evaluation / "manual_resolutions.txt" manual.write_text("### Nothing to do\n", encoding="utf-8") self.assertEqual(module.main([str(evaluation)]), 0) self.assertFalse(manual.exists()) self.assertEqual(read_json(evaluation / "correction.json"), {}) def test_malformed_manual_resolution_is_not_deleted(self) -> None: module = self.modules["resolve_manual"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" (evaluation / "Copies").mkdir(parents=True) correction = evaluation / "correction.json" atomic_write_json(correction, {}) manual = evaluation / "manual_resolutions.txt" manual.write_text("this is malformed\n", encoding="utf-8") before = correction.read_bytes() with redirect_stderr(io.StringIO()): self.assertEqual(module.main([str(evaluation)]), 1) self.assertTrue(manual.exists()) self.assertEqual(correction.read_bytes(), before) def test_documented_manual_resolution_operators_are_parsed(self) -> None: module = self.modules["resolve_manual"] with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "manual_resolutions.txt" path.write_text( "\n".join( f"Copie01 Old label {operator} New label" for operator in module.OPERATORS ), encoding="utf-8", ) instructions = module.parse_instructions(path) self.assertEqual( [instruction.operator for instruction in instructions], list(module.OPERATORS), ) def test_replace_manual_resolution_creates_refaire_state(self) -> None: module = self.modules["resolve_manual"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_dir = evaluation / "Copies" / "Copie01" copy_dir.mkdir(parents=True) (copy_dir / "Old.pdf").write_bytes(b"old answer") (copy_dir / "New.pdf").write_bytes(b"replaced answer") atomic_write_json( evaluation / "correction.json", { "Old": [ [ { "id": "01", "result": {"error": "wrg-lbl:New?delayed"}, } ] ], "New": [[{"id": "01", "result": {"error": ""}}]], }, ) (evaluation / "manual_resolutions.txt").write_text( "Copie01 Old -x New\n", encoding="utf-8" ) self.assertEqual(module.main([str(evaluation)]), 0) self.assertEqual((copy_dir / "New_old.pdf").read_bytes(), b"replaced answer") self.assertEqual((copy_dir / "New_new.pdf").read_bytes(), b"old answer") self.assertEqual( read_json(evaluation / "refaire.json"), [["Copie01", ["New"]]], ) correction = read_json(evaluation / "correction.json") self.assertEqual( correction["Old"][0][0]["result"]["error"], "wrg-lbl-moved-to:New", ) self.assertEqual(correction["New"][0][0]["result"]["suffix"], "_new") def test_verify_groups_reports_success_and_missing_answers(self) -> None: module = self.modules["verify_groups"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_dir = evaluation / "Copies" / "Copie01" group_dir = evaluation / "Par label" / "Ex 1" copy_dir.mkdir(parents=True) group_dir.mkdir(parents=True) (copy_dir / "Ex 1.pdf").write_bytes(b"pdf") self.assertEqual(module.main([str(evaluation)]), 1) atomic_write_json( group_dir / "Group_1.json", [["01", 0, 100, 1.0, "Ex 1"]], ) self.assertEqual(module.main([str(evaluation)]), 0) def test_checked_annotation_discovers_workspace_from_copy_pdf(self) -> None: module = self.modules["annotating_with_checks"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_pdf = evaluation / "Copies" / "Copie40.pdf" copy_pdf.parent.mkdir(parents=True) copy_pdf.write_bytes(b"pdf") args = module.build_parser().parse_args([str(copy_pdf)]) workspace, target = workspace_from_target(args) self.assertEqual(workspace.root, evaluation) self.assertEqual(target, copy_pdf) self.assertEqual(module._copy_id_from_target(workspace, target), "40") def test_checked_refaire_requires_refaire_file(self) -> None: module = self.modules["annotating_with_checks"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" (evaluation / "Copies").mkdir(parents=True) (evaluation / "Par label").mkdir() (evaluation / "labels").write_text("Ex 1\n", encoding="utf-8") atomic_write_json(evaluation / "correction.json", {}) with redirect_stderr(io.StringIO()): self.assertEqual(module.main([str(evaluation), "--refaire"]), 3) def test_grouped_reader_refaire_requires_refaire_file(self) -> None: module = self.modules["reading_grouped_annotations"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" (evaluation / "Copies").mkdir(parents=True) (evaluation / "Par label").mkdir() (evaluation / "BGnot").mkdir() (evaluation / "labels").write_text("Ex 1\n", encoding="utf-8") atomic_write_json(evaluation / "correction.json", {}) with redirect_stderr(io.StringIO()): self.assertEqual(module.main([str(evaluation), "--refaire"]), 3) def test_note_detection_accepts_a_missing_note_layer(self) -> None: module = self.modules["reading_annotations"] self.assertFalse(module.has_significant_notes(None)) def test_grouped_reader_reports_worker_failures(self) -> None: module = self.modules["reading_grouped_annotations"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" (evaluation / "Copies").mkdir(parents=True) (evaluation / "Par label").mkdir() (evaluation / "BGnot" / "Ex 1").mkdir(parents=True) (evaluation / "labels").write_text("Ex 1\n", encoding="utf-8") atomic_write_json(evaluation / "correction.json", {}) loaded = AnnotationLoadResult({"01": {"Ex 1": {}}}, []) with ( patch.object(module, "load_annotation_data", return_value=loaded), patch.object( module, "_scan_annotation_directory", side_effect=RuntimeError("worker failed"), ), redirect_stderr(io.StringIO()) as errors, ): self.assertEqual(module.main([str(evaluation)]), 1) self.assertIn("worker failed", errors.getvalue()) def test_checked_render_failure_preserves_previous_student_output(self) -> None: module = self.modules["annotating_with_checks"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" copy_dir = evaluation / "Copies" / "Copie01" copy_dir.mkdir(parents=True) (evaluation / "Par label").mkdir() (evaluation / "labels").write_text("Ex 1\n", encoding="utf-8") atomic_write_json(evaluation / "correction.json", {}) answer = copy_dir / "Ex 1.pdf" answer.write_bytes(b"pdf") previous = evaluation / "Bnot" / "Copie01" previous.mkdir(parents=True) (previous / "sentinel.txt").write_text("old", encoding="utf-8") loaded = AnnotationLoadResult( { "01": { "Ex 1": { "pdf_path": answer, "result": {"feedback": [], "score": 1}, "coordinates": (0, 0), } } }, [], ) with patch.object( module, "load_annotation_data", return_value=loaded ), patch.object( self.modules["annotating"], "make_base_image", side_effect=RuntimeError("render failed"), ), redirect_stderr(io.StringIO()): self.assertEqual(module.main([str(evaluation), "--overwrite"]), 1) self.assertEqual( (previous / "sentinel.txt").read_text(encoding="utf-8"), "old" ) def test_grouped_overwrite_preserves_previous_output_when_incomplete(self) -> None: module = self.modules["annotating_by_label"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" (evaluation / "Copies").mkdir(parents=True) (evaluation / "Par label").mkdir() (evaluation / "labels").write_text("Ex 1\n", encoding="utf-8") (evaluation / "label_groups").write_text("Ex 1\n", encoding="utf-8") atomic_write_json(evaluation / "correction.json", {}) previous = evaluation / "BGnot" previous.mkdir() (previous / "sentinel.txt").write_text("old", encoding="utf-8") loaded = AnnotationLoadResult( {"01": {"Ex 1": {"pdf_path": Path("missing")}}}, [], ) with patch.object( module, "load_annotation_data", return_value=loaded ), patch.object(module, "_generate_groups", return_value=(1, True)): self.assertEqual(module.main([str(evaluation), "--overwrite"]), 4) self.assertEqual( (previous / "sentinel.txt").read_text(encoding="utf-8"), "old" ) def test_grouped_annotations_default_to_gemini_question_groups(self) -> None: module = self.modules["annotating_by_label"] with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) / "Exam" items = evaluation / "Tmp" / "exam_items.txt" items.parent.mkdir(parents=True) items.write_text( "# edited Gemini groups\n" "Ex 1 ### First question\n" "CONTEXT ### Shared context\n" "Ex 2 ### Second question\n" "\n---\n\n" "Ex 3 ### Third question\n", encoding="utf-8", ) workspace = EvaluationWorkspace(evaluation) with patch.object(module.utils, "edit_file_and_enter") as editor: groups = module._load_label_groups( workspace, ["Ex 1", "Ex 2", "Ex 3"] ) self.assertEqual(groups, [["Ex 1", "Ex 2"], ["Ex 3"]]) self.assertEqual( workspace.label_groups_file.read_text(encoding="utf-8"), "Ex 1,Ex 2\nEx 3\n", ) editor.assert_called_once_with(workspace.label_groups_file) def test_grouped_annotations_fall_back_when_gemini_was_not_run(self) -> None: module = self.modules["annotating_by_label"] with tempfile.TemporaryDirectory() as directory: workspace = EvaluationWorkspace(Path(directory) / "Exam") workspace.root.mkdir() labels = ["Ex 1 : a", "Ex 1 : b", "Ex 2"] with patch.object(module.utils, "edit_file_and_enter"): groups = module._load_label_groups(workspace, labels) self.assertEqual(groups, [["Ex 1 : a", "Ex 1 : b"], ["Ex 2"]]) def test_existing_label_groups_override_gemini_defaults(self) -> None: module = self.modules["annotating_by_label"] with tempfile.TemporaryDirectory() as directory: workspace = EvaluationWorkspace(Path(directory) / "Exam") workspace.gemini_exam_items_file.parent.mkdir(parents=True) workspace.gemini_exam_items_file.write_text( "Ex 1 ### First\nEx 2 ### Second\n", encoding="utf-8" ) workspace.label_groups_file.write_text( "Ex 1\nEx 2\n", encoding="utf-8" ) with patch.object(module.utils, "edit_file_and_enter") as editor: groups = module._load_label_groups(workspace, ["Ex 1", "Ex 2"]) self.assertEqual(groups, [["Ex 1"], ["Ex 2"]]) editor.assert_not_called() def test_grouped_batching_does_not_split_one_student(self) -> None: module = self.modules["annotating_by_label"] image = Image.new("RGB", (10, 60), "white") rendered = [ ("01", "Ex 1", image, 0, []), ("01", "Ex 2", image, 0, []), ("02", "Ex 1", image, 0, []), ] with patch.object(module, "MAX_HEIGHT_PX", 100): batches = module.split_batches(rendered) self.assertEqual([len(batch) for batch in batches], [2, 1]) class WorkflowTests(unittest.TestCase): def setUp(self) -> None: self.steps = {step.id: step for step in build_workflow(True)} self.evaluation = evaluation_argument(REPOSITORY, REPOSITORY / "Example") def command(self, step_id: str, variant_id: str, values: dict[str, object]) -> list[str]: step = self.steps[step_id] variant = next(item for item in step.variants if item.id == variant_id) return build_command(REPOSITORY, step, variant, values, self.evaluation) def test_personal_steps_are_configurable(self) -> None: standard_ids = {step.id for step in build_workflow(False)} personal_ids = {step.id for step in build_workflow(True)} self.assertNotIn("update_ods", standard_ids) self.assertIn("update_ods", personal_ids) def test_get_personal_interro_files_copies_the_three_expected_sources(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) source = root / "source" evaluation = root / "Interro07" source.mkdir() evaluation.mkdir() contents = { "Interro07.pdf": b"pdf", "Interro07.tex": b"statement", "Interro07c.tex": b"correction", } for name, content in contents.items(): (source / name).write_bytes(content) copied = get_personal_interro_files(evaluation, source) self.assertEqual( copied, ( evaluation / "enonce.pdf", evaluation / "enonce.tex", evaluation / "correction.tex", ), ) self.assertEqual((evaluation / "enonce.pdf").read_bytes(), b"pdf") self.assertEqual((evaluation / "enonce.tex").read_bytes(), b"statement") self.assertEqual((evaluation / "correction.tex").read_bytes(), b"correction") def test_get_personal_interro_files_validates_name_and_all_sources_first(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) source = root / "source" source.mkdir() invalid = root / "DS07" invalid.mkdir() with self.assertRaisesRegex(ValueError, "Interro"): get_personal_interro_files(invalid, source) evaluation = root / "Interro07" evaluation.mkdir() (evaluation / "enonce.pdf").write_bytes(b"existing") (source / "Interro07.pdf").write_bytes(b"new") with self.assertRaisesRegex(FileNotFoundError, "Interro07.tex"): get_personal_interro_files(evaluation, source) self.assertEqual((evaluation / "enonce.pdf").read_bytes(), b"existing") def test_first_visit_automation_is_declared_on_expected_steps(self) -> None: auto_start = { step.id for step in self.steps.values() if step.auto_start_first_visit } skip_for_live = { step.id for step in self.steps.values() if step.skip_for_live_correction } skip_without_conflicts = { step.id for step in self.steps.values() if step.skip_without_manual_conflicts } self.assertEqual( auto_start, {"crop_blank_margins", "splitting", "crop_exercise_bottoms", "grouping"}, ) self.assertEqual( skip_for_live, {"submit_batches", "batch_status", "fetch_batches"} ) self.assertEqual(skip_without_conflicts, {"manual_resolution"}) def test_manual_resolution_precedes_post_correction(self) -> None: ordered_ids = [step.id for step in build_workflow(False)] self.assertLess( ordered_ids.index("manual_resolution"), ordered_ids.index("post_correction"), ) def test_crop_auto_start_follows_always_crop_configuration(self) -> None: for enabled in (False, True): with self.subTest(enabled=enabled), patch( "copienator_gui.workflow.ALWAYS_CROP", enabled ): steps = {step.id: step for step in build_workflow(False)} self.assertEqual( steps["crop_blank_margins"].auto_start_first_visit, enabled ) self.assertEqual( steps["crop_exercise_bottoms"].auto_start_first_visit, enabled ) self.assertTrue(steps["crop_blank_margins"].optional) self.assertTrue(steps["crop_exercise_bottoms"].optional) def test_default_and_repository_crop_configuration(self) -> None: import config import default_config self.assertFalse(default_config.ALWAYS_CROP) self.assertTrue(config.ALWAYS_CROP) def test_review_persp_has_shorter_title(self) -> None: self.assertEqual(self.steps["review_persp"].title, "Relire les barèmes") def test_cutleft_title_and_shortcuts_are_documented(self) -> None: step = self.steps["cutleft"] self.assertEqual( step.title, "Découper une partie à gauche pour détection des labels", ) for shortcut in ("n", "N", "t", "l", "1", "Entrée", "s"): with self.subTest(shortcut=shortcut): self.assertIn(shortcut, step.description) def test_export_has_shorter_title_and_annotation_argument(self) -> None: self.assertEqual(self.steps["export"].title, "Exporter") command = self.command( "export", "default", {"target": self.evaluation, "annotation_dir": "Bnot"}, ) self.assertEqual(command_arguments(command), [self.evaluation, "Bnot"]) def test_copy_listing_prefers_processed_copies(self) -> None: with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) (evaluation / "enonce.pdf").touch() (evaluation / "scan.pdf").touch() copies = evaluation / "Copies" copies.mkdir() (copies / "Copie02.pdf").touch() (copies / "Copie01.pdf").touch() self.assertEqual( [path.name for path in copy_pdf_paths(evaluation)], ["Copie01.pdf", "Copie02.pdf"], ) def test_annotation_directory_detection_uses_known_directories(self) -> None: with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) (evaluation / "Bnot").mkdir() (evaluation / "Anot").mkdir() (evaluation / "Other").mkdir() self.assertEqual( detected_annotation_directories(evaluation), ("Bnot", "Anot") ) def test_giving_names_utilities_find_and_rename_problematic_copies(self) -> None: with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) return_dir = evaluation / "A Rendre" original_dir = evaluation / "Copies Originales" return_dir.mkdir() original_dir.mkdir() (original_dir / "Copie01.pdf").write_bytes(b"original") folders = { "Unknown (01)": (b"unknown-jpg", b"unknown-pdf"), "Dupont (02)": (b"two-jpg", b"two-pdf"), "Dupont (03)": (b"three-jpg", b"three-pdf"), "Unique (04)": (b"four-jpg", b"four-pdf"), } for folder_name, (jpg, pdf) in folders.items(): folder = return_dir / folder_name folder.mkdir() base_name = folder_name.rsplit(" (", 1)[0] (folder / f"{base_name}.jpg").write_bytes(jpg) (folder / f"{base_name}.pdf").write_bytes(pdf) (folder / "score.json").write_text("{}", encoding="utf-8") (folder / "answers").mkdir() issues = find_name_issues(evaluation) self.assertEqual([issue.copy_id for issue in issues], ["01", "02", "03"]) self.assertEqual( original_copy_path(evaluation, "01"), original_dir / "Copie01.pdf", ) renamed = rename_return_copy(return_dir / "Unknown (01)", "Alice") self.assertEqual(renamed.name, "Alice (01)") self.assertEqual((renamed / "Alice.jpg").read_bytes(), b"unknown-jpg") self.assertEqual((renamed / "Alice.pdf").read_bytes(), b"unknown-pdf") self.assertTrue((renamed / "score.json").is_file()) self.assertTrue((renamed / "answers").is_dir()) self.assertEqual( [issue.copy_id for issue in find_name_issues(evaluation)], ["02", "03"], ) def test_export_and_import_defaults_follow_previous_runs(self) -> None: with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) (evaluation / "Bnot").mkdir() (evaluation / "Anot").mkdir() store = StateStore() store.load(evaluation) store.update_step("annotation", last_run_variant="simple") app = SimpleNamespace(evaluation=evaluation, state_store=store) choices, default = CopienatorApp._annotation_directory_choices( app, "export" ) self.assertEqual(choices, ("Bnot", "Anot")) self.assertEqual(default, "Anot") store.update_step( "export", last_run_values={"annotation_dir": "Bnot"} ) _choices, default = CopienatorApp._annotation_directory_choices( app, "import" ) self.assertEqual(default, "Bnot") def test_plotting_shortcuts_describe_open_actions(self) -> None: rendered = "\n".join(plotting_shortcut_lines()) self.assertIn("p : revenir à la précédente", rendered) self.assertIn("ouvrir l’énoncé", rendered) self.assertIn("ouvrir la copie traitée", rendered) self.assertIn("ouvrir la copie originale", rendered) def test_gui_tracks_correction_progress_across_output_chunks(self) -> None: app = object.__new__(CopienatorApp) app.active_step_id = "correction" app._correction_progress_buffer = "" app.info_var = Mock() app._track_correction_progress( "[Progression correction] Groupes trai" ) app.info_var.set.assert_not_called() app._track_correction_progress("tés : 3/8\n") app.info_var.set.assert_called_once_with( "Correction : 3 groupe(s) traité(s) sur 8 (38 %)." ) def test_proxy_is_opt_in_and_prefilled(self) -> None: base = {"HTTPS_PROXY": "http://system-proxy", "OTHER": "kept"} without_proxy = build_runner_environment( base, " secret ", DEFAULT_HTTPS_PROXY, False ) with_proxy = build_runner_environment(base, "", DEFAULT_HTTPS_PROXY, True) self.assertNotIn("HTTPS_PROXY", without_proxy) self.assertEqual(without_proxy["GEMINI_API_KEY"], "secret") self.assertEqual(without_proxy["OTHER"], "kept") self.assertEqual(with_proxy["HTTPS_PROXY"], DEFAULT_HTTPS_PROXY) def test_manual_conflicts_ignore_blank_and_comment_lines(self) -> None: with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "manual_resolutions.txt" self.assertFalse(has_manual_conflicts(path)) path.write_text("\n### Instructions\n ### exemple\n", encoding="utf-8") self.assertFalse(has_manual_conflicts(path)) path.write_text( "### Instructions\nCopie01 Ex 1 -> Ex 2\n", encoding="utf-8" ) self.assertTrue(has_manual_conflicts(path)) def test_live_correction_arguments(self) -> None: command = self.command( "correction", "live", {"target": self.evaluation, "overwrite": True, "limit": "0"}, ) self.assertEqual( command_arguments(command), [self.evaluation, "--overwrite", "--limit", "0"], ) self.assertEqual(command[4], "correct") def test_refaire_correction_does_not_offer_overwrite(self) -> None: step = self.steps["correction"] overwrite = next( argument for argument in step.arguments if argument.name == "overwrite" ) self.assertNotIn("refaire", overwrite.variants) command = self.command( "correction", "refaire", {"target": self.evaluation, "overwrite": True}, ) self.assertIn("--refaire", command) self.assertNotIn("--overwrite", command) def test_hybrid_correction_arguments(self) -> None: command = self.command( "correction", "hybrid", {"target": self.evaluation, "batch_from": "Ex 4"} ) self.assertEqual( command_arguments(command), [self.evaluation, "--batch-from", "Ex 4"] ) def test_only_multi_target_steps_offer_free_form_arguments(self) -> None: documented = { step.id: step for step in self.steps.values() if step.extra_arguments_help } self.assertEqual(set(documented), {"labels", "correction"}) self.assertIn("Cutleft", documented["labels"].extra_arguments_help) self.assertIn("Group_X.jpg", documented["correction"].extra_arguments_help) self.assertEqual( documented["correction"].extra_arguments_variants, ("live", "batch", "hybrid", "refaire"), ) def test_every_graphical_argument_has_tooltip_documentation(self) -> None: for step in self.steps.values(): for spec in step.arguments: with self.subTest(step=step.id, argument=spec.name): self.assertTrue(spec.help.strip()) self.assertTrue(spec.help.rstrip().endswith(".")) def test_final_score_documents_immediate_gestion_classe_prerequisite(self) -> None: description = self.steps["final_score"].description self.assertIn("gestion_classe wse", description) self.assertIn("juste avant", description) self.assertIn("histogramme.pdf", description) def test_options_previously_requiring_free_form_arguments_have_controls(self) -> None: status = self.command( "batch_status", "default", {"download": "files/job-1", "output": "/tmp/result.jsonl"}, ) self.assertEqual( command_arguments(status), ["--evaluation", self.evaluation], ) grouped = self.command( "read_annotations", "grouped", { "target": self.evaluation, "refaire": True, "annotation_dir": "Bnot", }, ) self.assertEqual( command_arguments(grouped), [ self.evaluation, "--refaire", "--annotation-dir", "Bnot", "--no-return-answers-context", "--return-answers-question", "--return-answers-solution", ], ) def test_return_answer_controls_follow_configuration_and_can_be_hidden(self) -> None: with patch.multiple( workflow_module.configuration, RETURN_ANSWERS_ENABLED=True, RETURN_ANSWERS_CONTEXT=True, RETURN_ANSWERS_QUESTION=False, RETURN_ANSWERS_SOLUTION=True, ): step = next( item for item in build_workflow(True) if item.id == "read_annotations" ) specs = {spec.name: spec for spec in step.arguments} self.assertIs(specs["return_answers_context"].default, True) self.assertIs(specs["return_answers_question"].default, False) self.assertIs(specs["return_answers_solution"].default, True) with patch.object( workflow_module.configuration, "RETURN_ANSWERS_ENABLED", False ): hidden_step = next( item for item in build_workflow(True) if item.id == "read_annotations" ) self.assertFalse( { "return_answers_context", "return_answers_question", "return_answers_solution", } & {spec.name for spec in hidden_step.arguments} ) def test_verbose_is_added_only_to_commands_that_support_it(self) -> None: labels = self.steps["labels"] labels_command = build_command( REPOSITORY, labels, labels.variants[0], {"target": self.evaluation}, self.evaluation, verbose=True, ) self.assertEqual(command_arguments(labels_command), [self.evaluation, "--verbose"]) rotate = self.steps["rotate"] rotate_command = build_command( REPOSITORY, rotate, rotate.variants[0], {"target": self.evaluation}, self.evaluation, verbose=True, ) self.assertEqual( command_arguments(rotate_command), ["rotate", self.evaluation, "--verbose"], ) update = self.steps["update_ods"] update_command = build_command( REPOSITORY, update, update.variants[0], {"target": self.evaluation}, self.evaluation, verbose=True, ) self.assertNotIn("--verbose", update_command) def test_annotation_variants_are_exclusive_commands(self) -> None: command = self.command( "annotation", "grouped", {"target": self.evaluation, "overwrite": True} ) self.assertEqual(command[4], "annotate-grouped") def test_copy_preparation_commands_are_python(self) -> None: rotate = self.command("rotate", "rotate", {"target": self.evaluation}) rename = self.command("rename", "rename", {"target": self.evaluation}) self.assertEqual(rotate[2:], ["-m", "copienator", "copies", "rotate", self.evaluation]) self.assertEqual(rename[2:], ["-m", "copienator", "copies", "rename", self.evaluation]) def test_clean_is_the_last_dangerous_gui_step(self) -> None: ordered_steps = build_workflow(True) clean = ordered_steps[-1] self.assertEqual(clean.id, "clean") self.assertTrue(clean.optional) self.assertTrue(clean.variants[0].dangerous) self.assertIn("progression", clean.variants[0].danger_warning) self.assertEqual( command_arguments( self.command("clean", "default", {"target": self.evaluation}) ), [self.evaluation, "--yes"], ) preview = next(variant for variant in clean.variants if variant.id == "dry_run") self.assertFalse(preview.dangerous) self.assertEqual( command_arguments( build_command( REPOSITORY, clean, preview, {"target": self.evaluation}, self.evaluation, ) ), [self.evaluation, "--dry-run"], ) class CrossPlatformFileTests(unittest.TestCase): def test_rotate_all_skips_statement(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) for filename in ("Copie.pdf", "enonce.pdf"): writer = PdfWriter() writer.add_blank_page(width=100, height=200) with (root / filename).open("wb") as output: writer.write(output) self.assertEqual(rotate_all(root), 1) self.assertEqual(PdfReader(root / "Copie.pdf").pages[0].rotation, 180) self.assertEqual(PdfReader(root / "enonce.pdf").pages[0].rotation, 0) def test_rename_all_is_collision_safe(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / "z.pdf").write_bytes(b"z") (root / "a.pdf").write_bytes(b"a") (root / "Copie01.pdf").write_bytes(b"existing") (root / "enonce.pdf").write_bytes(b"statement") rename_all(root) self.assertEqual((root / "Copie01.pdf").read_bytes(), b"a") self.assertEqual((root / "Copie02.pdf").read_bytes(), b"existing") self.assertEqual((root / "Copie03.pdf").read_bytes(), b"z") self.assertEqual((root / "enonce.pdf").read_bytes(), b"statement") def test_link_falls_back_to_copy(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) source = root / "source.txt" destination = root / "destination.txt" source.write_text("content", encoding="utf-8") with patch("copienator.platform.os.link", side_effect=OSError), patch( "copienator.platform.os.symlink", side_effect=OSError ): method = replace_with_link_or_copy(source, destination) self.assertEqual(method, "copy") self.assertEqual(destination.read_text(encoding="utf-8"), "content") def test_windows_safe_student_filename(self) -> None: self.assertEqual(safe_filename('Jean: Dupont? '), "Jean Dupont") self.assertEqual(safe_filename("CON"), "_CON") self.assertEqual(safe_filename("name..."), "name") def test_windows_labels_are_rejected_without_modification(self) -> None: labels = ["Ex 1", "Ex 2 : a)", "AUX", "Ex 3."] with self.assertRaises(WindowsLabelError) as context: validate_windows_labels(labels, platform_name="nt") message = str(context.exception) self.assertIn("Ex 2 : a)", message) self.assertIn("AUX", message) self.assertIn("Ex 3.", message) self.assertEqual(labels[1], "Ex 2 : a)") def test_windows_safe_labels_are_accepted(self) -> None: validate_windows_labels(["Ex 1 - a)", "Question 2"], platform_name="nt") validate_windows_labels(["Ex 1 : a)"], platform_name="posix") self.assertTrue(windows_filename_problems("Ex 1 : a)")) def test_diagnostics_include_supported_platform(self) -> None: checks = collect_diagnostics(False, "test-key") system = next(check for check in checks if check.name == "Système") key = next(check for check in checks if check.name == "Clé Gemini") self.assertTrue(system.ok) self.assertTrue(key.ok) def test_diagnostics_detect_windows_unsafe_labels(self) -> None: with tempfile.TemporaryDirectory() as directory: evaluation = Path(directory) (evaluation / "labels").write_text("Ex 1 : a)\nEx 2\n", encoding="utf-8") checks = collect_diagnostics(False, "test-key", evaluation) labels = next(check for check in checks if check.name == "Labels compatibles Windows") self.assertFalse(labels.ok) self.assertIn("Ex 1 : a)", labels.detail) class RunnerTests(unittest.TestCase): def wait_for_finish(self, runner: ProcessRunner, timeout: float = 5) -> tuple[int, bool]: deadline = time.time() + timeout while time.time() < deadline: try: event, payload = runner.events.get(timeout=0.2) except queue.Empty: continue if event == "finished": return payload self.fail("Le sous-processus ne s’est pas terminé") def test_interactive_input_and_output(self) -> None: with tempfile.TemporaryDirectory() as directory: runner = ProcessRunner() runner.start( [ sys.executable, "-u", "-c", "value=input('réponse ? '); print('reçu:', value)", ], REPOSITORY, os.environ.copy(), Path(directory) / "runner.log", ) time.sleep(0.1) runner.send_input("oui") self.assertEqual(self.wait_for_finish(runner), (0, False)) self.assertIn("reçu: oui", (Path(directory) / "runner.log").read_text(encoding="utf-8")) if __name__ == "__main__": unittest.main()