295 lines
12 KiB
Python
295 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import queue
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
import unittest
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from pathlib import Path
|
||
from unittest.mock import patch
|
||
|
||
from pypdf import PdfReader, PdfWriter
|
||
|
||
from copienator import (
|
||
EvaluationWorkspace,
|
||
WorkspaceNotFoundError,
|
||
WorkspaceValidationError,
|
||
atomic_update_json,
|
||
atomic_write_json,
|
||
read_json,
|
||
)
|
||
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]
|
||
|
||
|
||
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"])
|
||
|
||
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 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()
|