EvaluationWorkspace
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user