Files
Copies/tests/test_gui_core.py
T
2026-08-20 14:35:04 +02:00

826 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 sest 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()