Miscs improvements (Interro02)
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import contextlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pandas as pd
|
||||
from PIL import Image
|
||||
|
||||
from copienator.commands import add_final_score
|
||||
|
||||
|
||||
class AddFinalScoreTests(unittest.TestCase):
|
||||
def test_creates_complete_student_directory(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "A Rendre" / "Student (01)"
|
||||
answers = source / "answers"
|
||||
answers.mkdir(parents=True)
|
||||
Image.new("RGB", (300, 200), "white").save(source / "Student.jpg")
|
||||
(source / "Student.pdf").write_bytes(b"pdf")
|
||||
(source / "score.json").write_bytes(b'{"Ex 1": "4"}')
|
||||
(source / "info.json").write_bytes(b'{"Ex 1": {}}')
|
||||
(answers / "Ex 1.jpg").write_bytes(b"answer")
|
||||
|
||||
output = root / "output"
|
||||
output.mkdir()
|
||||
(output / "Student.jpg").write_bytes(b"legacy jpeg")
|
||||
(output / "Student.pdf").write_bytes(b"legacy pdf")
|
||||
stale_answers = output / "Student" / "answers"
|
||||
stale_answers.mkdir(parents=True)
|
||||
(stale_answers / "001 - Ex 1.jpg").write_bytes(b"stale")
|
||||
scores = pd.DataFrame({0: ["Student"], 1: [12.39]})
|
||||
histogram = root / "histogramme.pdf"
|
||||
histogram.write_bytes(b"histogram")
|
||||
with patch.object(add_final_score.pd, "read_excel", return_value=scores), \
|
||||
patch.object(add_final_score, "HISTOGRAM_PATH", histogram), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
add_final_score.process_images(root, output)
|
||||
|
||||
student = output / "Student"
|
||||
self.assertEqual(
|
||||
{path.name for path in student.iterdir()},
|
||||
{"Student.jpg", "Student.pdf", "score.json", "info.json", "answers"},
|
||||
)
|
||||
self.assertEqual((student / "Student.pdf").read_bytes(), b"pdf")
|
||||
self.assertEqual((student / "score.json").read_bytes(), b'{"Ex 1": "4"}')
|
||||
self.assertEqual((student / "info.json").read_bytes(), b'{"Ex 1": {}}')
|
||||
self.assertEqual(
|
||||
(student / "answers" / "Ex 1.jpg").read_bytes(), b"answer"
|
||||
)
|
||||
self.assertFalse((output / "Student.jpg").exists())
|
||||
self.assertFalse((output / "Student.pdf").exists())
|
||||
self.assertFalse((student / "answers" / "001 - Ex 1.jpg").exists())
|
||||
self.assertEqual((output / "histogramme.pdf").read_bytes(), b"histogram")
|
||||
|
||||
def test_omits_answers_directory_when_it_was_not_generated(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "A Rendre" / "Student (01)"
|
||||
source.mkdir(parents=True)
|
||||
Image.new("RGB", (300, 200), "white").save(source / "Student.jpg")
|
||||
(source / "Student.pdf").write_bytes(b"pdf")
|
||||
(source / "score.json").write_text("{}")
|
||||
(source / "info.json").write_text("{}")
|
||||
|
||||
output = root / "output"
|
||||
scores = pd.DataFrame({0: ["Student"], 1: [10]})
|
||||
with patch.object(add_final_score.pd, "read_excel", return_value=scores), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
add_final_score.process_images(root, output)
|
||||
|
||||
self.assertFalse((output / "Student" / "answers").exists())
|
||||
|
||||
def test_missing_histogram_warns_without_discarding_student_outputs(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "A Rendre" / "Student (01)"
|
||||
source.mkdir(parents=True)
|
||||
Image.new("RGB", (300, 200), "white").save(source / "Student.jpg")
|
||||
scores = pd.DataFrame({0: ["Student"], 1: [10]})
|
||||
output = root / "output"
|
||||
messages = io.StringIO()
|
||||
|
||||
with patch.object(
|
||||
add_final_score.pd, "read_excel", return_value=scores
|
||||
), patch.object(
|
||||
add_final_score, "HISTOGRAM_PATH", root / "missing.pdf"
|
||||
), contextlib.redirect_stdout(messages):
|
||||
add_final_score.process_images(root, output)
|
||||
|
||||
self.assertTrue((output / "Student" / "Student.jpg").is_file())
|
||||
self.assertFalse((output / "histogramme.pdf").exists())
|
||||
self.assertIn("Missing histogram", messages.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,7 +11,10 @@ from PIL import Image
|
||||
from copienator import EvaluationWorkspace, atomic_write_json, configuration, read_json
|
||||
from copienator.commands import annotating, giving_names
|
||||
from copienator.commands import reading_grouped_annotations as reader
|
||||
from copienator.return_answers import publish_answer_returns
|
||||
from copienator.return_answers import (
|
||||
publish_answer_returns,
|
||||
save_return_answer_options,
|
||||
)
|
||||
|
||||
|
||||
class AnswerReturnTests(unittest.TestCase):
|
||||
@@ -54,7 +57,7 @@ class AnswerReturnTests(unittest.TestCase):
|
||||
), patch.object(annotating, "make_base_image", side_effect=self.supplement):
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
files = sorted((self.destination / "answers").glob("*.jpg"))
|
||||
self.assertEqual([p.name for p in files], ["001 - Ex 1.jpg", "002 - Ex 2.jpg"])
|
||||
self.assertEqual([p.name for p in files], ["Ex 1.jpg", "Ex 2.jpg"])
|
||||
with Image.open(files[0]) as image:
|
||||
self.assertEqual(image.size, (100, 30 + 20 * sum((context, question, solution))))
|
||||
colors = []
|
||||
@@ -70,6 +73,38 @@ class AnswerReturnTests(unittest.TestCase):
|
||||
self.assertTrue(all(abs(a - b) < 10 for a, b in zip(pixel, color)))
|
||||
self.assertEqual(read_json(self.destination / "info.json"), read_json(self.source / "info.json"))
|
||||
|
||||
def test_saved_reading_options_override_configuration_for_answers(self):
|
||||
save_return_answer_options(
|
||||
self.root,
|
||||
context=True,
|
||||
question=False,
|
||||
solution=True,
|
||||
)
|
||||
with patch.multiple(
|
||||
configuration,
|
||||
RETURN_ANSWERS_ENABLED=True,
|
||||
RETURN_ANSWERS_CONTEXT=False,
|
||||
RETURN_ANSWERS_QUESTION=True,
|
||||
RETURN_ANSWERS_SOLUTION=False,
|
||||
), patch.object(
|
||||
annotating, "make_base_image", side_effect=self.supplement
|
||||
):
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
|
||||
with Image.open(self.destination / "answers" / "Ex 1.jpg") as image:
|
||||
self.assertEqual(image.size, (100, 70))
|
||||
for y, color in (
|
||||
(10, (0, 0, 255)),
|
||||
(30, (255, 255, 0)),
|
||||
(50, (255, 0, 0)),
|
||||
):
|
||||
self.assertTrue(
|
||||
all(
|
||||
abs(actual - expected) < 10
|
||||
for actual, expected in zip(image.getpixel((50, y)), color)
|
||||
)
|
||||
)
|
||||
|
||||
def test_missing_supplement_is_optional_and_failure_preserves_old_answers(self):
|
||||
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
|
||||
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=True,
|
||||
@@ -78,7 +113,7 @@ class AnswerReturnTests(unittest.TestCase):
|
||||
):
|
||||
(self.root / "Text2" / "Ex 1.pdf").unlink()
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
path = self.destination / "answers" / "001 - Ex 1.jpg"
|
||||
path = self.destination / "answers" / "Ex 1.jpg"
|
||||
with Image.open(path) as image:
|
||||
self.assertEqual(image.size, (100, 30))
|
||||
original = path.read_bytes()
|
||||
@@ -86,7 +121,7 @@ class AnswerReturnTests(unittest.TestCase):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
publish_answer_returns(self.root, self.source, self.destination)
|
||||
self.assertEqual(path.read_bytes(), original)
|
||||
self.assertTrue((self.destination / "answers" / "002 - Ex 2.jpg").exists())
|
||||
self.assertTrue((self.destination / "answers" / "Ex 2.jpg").exists())
|
||||
|
||||
def test_disabling_clears_individual_images_but_retains_info(self):
|
||||
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
|
||||
@@ -135,8 +170,99 @@ class AnswerReturnTests(unittest.TestCase):
|
||||
self.assertEqual(giving_names.run(workspace, annotation_dir="BGnot"), 0)
|
||||
self.assertEqual({p.name for p in self.destination.iterdir()}, {"answers", "score.json", "info.json"})
|
||||
|
||||
def test_update_uses_copy_id_from_renamed_folder_and_touches_only_answers(self):
|
||||
workspace = EvaluationWorkspace(self.root)
|
||||
workspace.copies_dir.mkdir()
|
||||
atomic_write_json(workspace.copies_dir / "Copie01.json", {"name": "Student"})
|
||||
renamed = self.destination.with_name("Nom modifié manuellement (01)")
|
||||
self.destination.rename(renamed)
|
||||
(renamed / "Nom personnalisé.jpg").write_bytes(b"keep-jpeg")
|
||||
(renamed / "Nom personnalisé.pdf").write_bytes(b"keep-pdf")
|
||||
(renamed / "score.json").write_bytes(b"keep-score")
|
||||
(renamed / "info.json").write_bytes(b"keep-info")
|
||||
answers = renamed / "answers"
|
||||
answers.mkdir()
|
||||
(answers / "obsolete.jpg").write_bytes(b"obsolete")
|
||||
preserved = {
|
||||
path.name: path.read_bytes()
|
||||
for path in renamed.iterdir()
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
with patch.multiple(
|
||||
configuration,
|
||||
RETURN_ANSWERS_ENABLED=True,
|
||||
RETURN_ANSWERS_CONTEXT=False,
|
||||
RETURN_ANSWERS_QUESTION=False,
|
||||
RETURN_ANSWERS_SOLUTION=False,
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(
|
||||
giving_names.run(workspace, annotation_dir="BGnot", update=True),
|
||||
0,
|
||||
)
|
||||
|
||||
self.assertFalse((workspace.return_dir / "Student (01)").exists())
|
||||
self.assertEqual(
|
||||
{
|
||||
path.name: path.read_bytes()
|
||||
for path in renamed.iterdir()
|
||||
if path.is_file()
|
||||
},
|
||||
preserved,
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in answers.iterdir()),
|
||||
["Ex 1.jpg", "Ex 2.jpg"],
|
||||
)
|
||||
|
||||
|
||||
class CompiledMembershipTests(unittest.TestCase):
|
||||
def test_update_score_preserves_file_and_manual_value_wins(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
workspace = EvaluationWorkspace(Path(directory))
|
||||
output = workspace.root / "BGnot" / "Copie01"
|
||||
output.mkdir(parents=True)
|
||||
original_score = b'{\n "Ex 1": "3.5"\n}\n'
|
||||
(output / "score.json").write_bytes(original_score)
|
||||
answer = workspace.root / "answer.pdf"
|
||||
answer.touch()
|
||||
data = {
|
||||
"01": {
|
||||
"Ex 1": {
|
||||
"result": {"score": 1, "feedback": []},
|
||||
"pdf_path": answer,
|
||||
"coordinates": (0, 0),
|
||||
}
|
||||
}
|
||||
}
|
||||
rendered_scores = []
|
||||
|
||||
def compose(base, label, result, *args, **kwargs):
|
||||
rendered_scores.append(result["score"])
|
||||
return Image.new("RGB", (100, 50), "white"), 0
|
||||
|
||||
with patch.object(
|
||||
annotating, "make_base_image", return_value=(None, 0, 0)
|
||||
), patch.object(
|
||||
annotating, "compose_label_image", side_effect=compose
|
||||
), patch.object(
|
||||
reader, "get_extra_pdfs_as_images", return_value=[]
|
||||
), patch.object(reader, "save_paginated_pdf"):
|
||||
status, _ = reader.apply_actions_and_regenerate_grouped(
|
||||
workspace,
|
||||
data,
|
||||
"01",
|
||||
[{"label": "Ex 1", "type": "score", "value": "2"}],
|
||||
{},
|
||||
["Ex 1"],
|
||||
update_score=True,
|
||||
)
|
||||
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(rendered_scores, ["3.5"])
|
||||
self.assertEqual((output / "score.json").read_bytes(), original_score)
|
||||
self.assertEqual(read_json(output / "info.json")["Ex 1"]["score"], "3.5")
|
||||
|
||||
def test_membership_matches_actual_groups_and_saves_every_nonempty_answer(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
|
||||
+106
-2
@@ -46,6 +46,7 @@ from copienator_gui.app import (
|
||||
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 (
|
||||
@@ -490,7 +491,7 @@ class StandardCliTests(unittest.TestCase):
|
||||
"giving_names": (
|
||||
"giving_names",
|
||||
"default",
|
||||
{"target": evaluation, "annotation_dir": "BGnot"},
|
||||
{"target": evaluation, "annotation_dir": "BGnot", "update": True},
|
||||
),
|
||||
"grouping": ("grouping", "default", {"target": evaluation}),
|
||||
"post_correction": (
|
||||
@@ -1225,6 +1226,27 @@ class StandardCliTests(unittest.TestCase):
|
||||
self.assertEqual(module.results, {"Ex 1": []})
|
||||
self.assertEqual(module.tasks_to_process, [("new.jpg", "Ex 1")])
|
||||
|
||||
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_correction_reset_restores_old_and_deletes_new_files(self) -> None:
|
||||
module = self.modules["correction"]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -1333,6 +1355,43 @@ class StandardCliTests(unittest.TestCase):
|
||||
|
||||
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()
|
||||
@@ -1892,6 +1951,12 @@ class WorkflowTests(unittest.TestCase):
|
||||
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",
|
||||
@@ -1914,7 +1979,46 @@ class WorkflowTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
command_arguments(grouped),
|
||||
[self.evaluation, "--refaire", "--annotation-dir", "Bnot"],
|
||||
[
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import contextlib
|
||||
import io
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator import utils
|
||||
|
||||
|
||||
class CompileToPdfTests(unittest.TestCase):
|
||||
def test_warns_when_latex_fails_even_if_partial_pdf_exists(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "result.pdf"
|
||||
|
||||
def failed_run(*_args, **kwargs):
|
||||
(Path(kwargs["cwd"]) / "text.pdf").write_bytes(b"partial")
|
||||
return subprocess.CompletedProcess(
|
||||
args=[], returncode=1,
|
||||
stdout="! LaTeX Error: Something's wrong--perhaps a missing \\item.\n",
|
||||
)
|
||||
|
||||
console = io.StringIO()
|
||||
with patch.object(utils.subprocess, "run", side_effect=failed_run), \
|
||||
contextlib.redirect_stdout(console):
|
||||
utils.compile_to_pdf("broken", output)
|
||||
|
||||
self.assertEqual(output.read_bytes(), b"partial")
|
||||
self.assertIn("Warning: LaTeX compilation failed", console.getvalue())
|
||||
self.assertIn("missing \\item", console.getvalue())
|
||||
|
||||
def test_warns_when_no_pdf_is_produced(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary) / "result.pdf"
|
||||
result = subprocess.CompletedProcess(args=[], returncode=0, stdout="")
|
||||
|
||||
console = io.StringIO()
|
||||
with patch.object(utils.subprocess, "run", return_value=result), \
|
||||
contextlib.redirect_stdout(console):
|
||||
utils.compile_to_pdf("valid", output)
|
||||
|
||||
self.assertFalse(output.exists())
|
||||
self.assertIn("produced no PDF", console.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user