826 lines
36 KiB
Python
826 lines
36 KiB
Python
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import io
|
||
import os
|
||
import queue
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import unittest
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from contextlib import redirect_stderr
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
from PIL import Image
|
||
from pypdf import PdfReader, PdfWriter
|
||
|
||
from copienator import (
|
||
EvaluationWorkspace,
|
||
WorkspaceNotFoundError,
|
||
WorkspaceValidationError,
|
||
atomic_update_json,
|
||
atomic_write_json,
|
||
read_json,
|
||
workspace_from_target,
|
||
)
|
||
from copienator.annotation_data import AnnotationLoadResult, load_annotation_data
|
||
from copienator.filesystem import staged_directory
|
||
from copienator_gui.app import process_status
|
||
from copienator_gui.diagnostics import collect_diagnostics
|
||
from copienator_gui.runner import ProcessRunner
|
||
from copienator_gui.state import StateStore
|
||
from copienator_gui.workflow import build_command, build_workflow, evaluation_argument
|
||
from copies_tools import rename_all, rotate_all
|
||
from platform_utils 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):
|
||
spec = importlib.util.spec_from_file_location(module_name, REPOSITORY / filename)
|
||
if spec is None or spec.loader is None:
|
||
raise RuntimeError(f"Could not load {filename}")
|
||
module = importlib.util.module_from_spec(spec)
|
||
sys.modules[module_name] = module
|
||
try:
|
||
spec.loader.exec_module(module)
|
||
except Exception:
|
||
sys.modules.pop(module_name, None)
|
||
raise
|
||
return module
|
||
|
||
|
||
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.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 AtomicJsonTests(unittest.TestCase):
|
||
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")
|
||
|
||
|
||
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, [])
|
||
|
||
|
||
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"
|
||
),
|
||
"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],
|
||
}
|
||
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 = {
|
||
"export": ("export", "default", {"target": evaluation, "refaire": True}),
|
||
"import": ("import", "default", {"target": evaluation, "refaire": True}),
|
||
"giving_names": (
|
||
"giving_names",
|
||
"default",
|
||
{"target": evaluation, "annotation_dir": "BGnot"},
|
||
),
|
||
"grouping": ("grouping", "default", {"target": evaluation}),
|
||
"post_correction": (
|
||
"post_correction",
|
||
"default",
|
||
{"target": evaluation},
|
||
),
|
||
"resolve_manual": (
|
||
"manual_resolution",
|
||
"default",
|
||
{"target": evaluation},
|
||
),
|
||
"verify_groups": (
|
||
"verify_groups",
|
||
"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},
|
||
),
|
||
}
|
||
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[3:])
|
||
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[3:]
|
||
)
|
||
self.assertEqual(parsed.operation, step_id)
|
||
self.assertEqual(str(parsed.evaluation), evaluation)
|
||
|
||
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_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_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})
|
||
(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_post_correction_main_cleans_json_atomically(self) -> None:
|
||
module = self.modules["post_correction"]
|
||
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"},
|
||
)
|
||
self.assertEqual(module.main([str(evaluation)]), 0)
|
||
self.assertEqual(
|
||
read_json(evaluation / "correction.json"),
|
||
{"text": r"outside\_name and $math_name$", "suffix": "_new"},
|
||
)
|
||
|
||
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_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_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_live_correction_arguments(self) -> None:
|
||
command = self.command(
|
||
"correction",
|
||
"live",
|
||
{"target": self.evaluation, "overwrite": True, "limit": "0"},
|
||
)
|
||
self.assertEqual(command[3:], [self.evaluation, "--overwrite", "--limit", "0"])
|
||
self.assertIn("correction.py", command[2])
|
||
|
||
def test_hybrid_correction_arguments(self) -> None:
|
||
command = self.command(
|
||
"correction", "hybrid", {"target": self.evaluation, "batch_from": "Ex 4"}
|
||
)
|
||
self.assertEqual(command[3:], [self.evaluation, "--batch-from", "Ex 4"])
|
||
|
||
def test_annotation_variants_are_exclusive_commands(self) -> None:
|
||
command = self.command(
|
||
"annotation", "grouped", {"target": self.evaluation, "overwrite": True}
|
||
)
|
||
self.assertIn("annotating_by_label.py", command[2])
|
||
self.assertNotIn("annotating.py", command[2])
|
||
|
||
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:], [str(REPOSITORY / "copies_tools.py"), "rotate", self.evaluation])
|
||
self.assertEqual(rename[2:], [str(REPOSITORY / "copies_tools.py"), "rename", self.evaluation])
|
||
|
||
|
||
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("platform_utils.os.link", side_effect=OSError), patch(
|
||
"platform_utils.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()
|