Manual score modification step
This commit is contained in:
1 parent
261a50a7d2
commit
7bc588e9a1
5 files changed
+383
-2
No files matched your search
@@ -0,0 +1,94 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator import CliError, EvaluationWorkspace, ExitCode
|
||||
from copienator.annotation_data import AnnotationLoadResult
|
||||
from copienator.commands import annotating as simple
|
||||
from copienator.commands import annotating_by_label as grouped
|
||||
from copienator.commands import annotating_with_checks as checks
|
||||
from copienator.commands import import_annotations
|
||||
|
||||
|
||||
class AnnotationImportCleanupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
self.workspace = EvaluationWorkspace(self.root / "Exam")
|
||||
for directory in ("Copies", "Par label"):
|
||||
(self.workspace.root / directory).mkdir(parents=True)
|
||||
(self.workspace.root / "labels").write_text("A\n")
|
||||
(self.workspace.root / "correction.json").write_text("{}")
|
||||
self.import_dir = self.root / "Annotées"
|
||||
self.import_dir.mkdir()
|
||||
self.patch = patch.object(import_annotations, "IMPORT_DIR", self.import_dir)
|
||||
self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
|
||||
def test_cleanup_empties_folder_without_following_child_symlinks(self):
|
||||
(self.import_dir / "old.pdf").write_bytes(b"return")
|
||||
nested = self.import_dir / "nested"
|
||||
nested.mkdir()
|
||||
(nested / "old.jpg").write_bytes(b"return")
|
||||
outside = self.root / "keep"
|
||||
outside.mkdir()
|
||||
(outside / "keep.pdf").write_bytes(b"keep")
|
||||
(self.import_dir / "link").symlink_to(outside, target_is_directory=True)
|
||||
import_annotations.clear_import_directory(self.workspace)
|
||||
self.assertTrue(self.import_dir.is_dir())
|
||||
self.assertEqual(list(self.import_dir.iterdir()), [])
|
||||
self.assertTrue((outside / "keep.pdf").is_file())
|
||||
|
||||
def test_cleanup_handles_unconfigured_or_missing_folder(self):
|
||||
for configured in (None, "", self.root / "absent"):
|
||||
with self.subTest(configured=configured), \
|
||||
patch.object(import_annotations, "IMPORT_DIR", configured):
|
||||
import_annotations.clear_import_directory(self.workspace)
|
||||
self.assertFalse((self.root / "absent").exists())
|
||||
|
||||
def test_cleanup_rejects_an_ancestor_of_evaluation(self):
|
||||
with patch.object(import_annotations, "IMPORT_DIR", self.root), \
|
||||
self.assertRaises(CliError):
|
||||
import_annotations.clear_import_directory(self.workspace)
|
||||
self.assertTrue(self.workspace.root.is_dir())
|
||||
|
||||
def test_all_modes_clean_only_when_checked_and_successful(self):
|
||||
for module in (simple, checks, grouped):
|
||||
for clear_import, warnings in ((False, []), (True, []), (True, ["missing answer"])):
|
||||
with self.subTest(mode=module.__name__, checked=clear_import, warnings=warnings):
|
||||
old = self.import_dir / "old.pdf"
|
||||
old.write_bytes(b"return")
|
||||
with ExitStack() as patches:
|
||||
patches.enter_context(patch.object(
|
||||
module, "load_annotation_data",
|
||||
return_value=AnnotationLoadResult({"01": {"A": {}}}, warnings),
|
||||
))
|
||||
if module is simple:
|
||||
patches.enter_context(patch.object(
|
||||
simple, "process_correction", return_value=ExitCode.SUCCESS
|
||||
))
|
||||
elif module is checks:
|
||||
patches.enter_context(patch.object(
|
||||
checks, "_render_student", return_value="success"
|
||||
))
|
||||
else:
|
||||
patches.enter_context(patch.object(
|
||||
grouped, "_generate_groups", return_value=(1, 0)
|
||||
))
|
||||
patches.enter_context(patch.object(
|
||||
grouped, "_load_label_groups", return_value=[["A"]]
|
||||
))
|
||||
targets = (self.workspace.root,) if module is checks else ()
|
||||
status = module.run(self.workspace, *targets, clear_import=clear_import)
|
||||
self.assertEqual(status, ExitCode.PARTIAL if warnings else ExitCode.SUCCESS)
|
||||
self.assertEqual(old.exists(), not (clear_import and not warnings))
|
||||
|
||||
def test_cli_forwards_cleanup_flag_for_each_mode(self):
|
||||
for module in (simple, checks, grouped):
|
||||
with self.subTest(mode=module.__name__), \
|
||||
patch.object(module, "run", return_value=ExitCode.SUCCESS) as run:
|
||||
self.assertEqual(module.main([str(self.workspace.root), "--clear-import"]), 0)
|
||||
self.assertTrue(run.call_args.kwargs["clear_import"])
|
||||
@@ -0,0 +1,113 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator import read_json
|
||||
from copienator_gui.app import CopienatorApp
|
||||
from copienator_gui.scores import score_path_for_id, set_score
|
||||
from copienator_gui.workflow import build_workflow
|
||||
|
||||
|
||||
class ScoreEditorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.evaluation = Path(temporary.name)
|
||||
folder = self.evaluation / "A Rendre" / "Student (01)"
|
||||
folder.mkdir(parents=True)
|
||||
self.path = folder / "score.json"
|
||||
self.path.write_text('{"Ex 1": "2", "Ex 2": "4"}')
|
||||
|
||||
def test_step_is_optional_and_follows_return_preparation_in_both_profiles(self):
|
||||
for personal in (False, True):
|
||||
steps = build_workflow(personal)
|
||||
index = next(index for index, step in enumerate(steps) if step.id == "giving_names")
|
||||
self.assertEqual(steps[index + 1].id, "edit_scores")
|
||||
self.assertTrue(steps[index + 1].optional)
|
||||
self.assertTrue(steps[index + 1].is_manual)
|
||||
|
||||
def test_update_preserves_other_labels_and_follows_symlinks(self):
|
||||
source = self.evaluation / "source-score.json"
|
||||
self.path.rename(source)
|
||||
self.path.symlink_to(source)
|
||||
path = score_path_for_id(self.evaluation, "1")
|
||||
self.assertEqual(path, score_path_for_id(self.evaluation, "01"))
|
||||
self.assertEqual(set_score(path, "Ex 1", "3,5"), "2")
|
||||
self.assertTrue(path.is_symlink())
|
||||
self.assertEqual(read_json(source), {"Ex 1": "3.5", "Ex 2": "4"})
|
||||
self.assertEqual(read_json(path), read_json(source))
|
||||
|
||||
def test_invalid_score_or_missing_label_does_not_modify_file(self):
|
||||
before = self.path.read_bytes()
|
||||
for value in ("", "abc", "NaN", "Infinity", "-1"):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
set_score(self.path, "Ex 1", value)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
with self.assertRaises(ValueError):
|
||||
set_score(self.path, "Missing", "3")
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_missing_and_ambiguous_ids_are_rejected(self):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
score_path_for_id(self.evaluation, "2")
|
||||
with self.assertRaises(ValueError):
|
||||
score_path_for_id(self.evaluation, "../1")
|
||||
(self.path.parent.parent / "Other (1)").mkdir()
|
||||
with self.assertRaises(ValueError):
|
||||
score_path_for_id(self.evaluation, "1")
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
|
||||
class ScoreEditorGuiTests(ScoreEditorTests):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app = CopienatorApp(Path.cwd(), True, self.evaluation)
|
||||
self.addCleanup(self.close_app)
|
||||
self.app.update()
|
||||
self.app.tree.selection_set("edit_scores")
|
||||
self.app.update()
|
||||
self.panel = self.app.score_editor_panel
|
||||
|
||||
def close_app(self):
|
||||
for callback in self.app.tk.splitlist(self.app.tk.call("after", "info")):
|
||||
self.app.after_cancel(callback)
|
||||
self.app.destroy()
|
||||
|
||||
def test_selection_loads_score_and_set_keeps_editor_open(self):
|
||||
self.assertEqual(str(self.panel.set_button.cget("state")), "disabled")
|
||||
self.panel.id_var.set("1")
|
||||
self.assertEqual(tuple(self.panel.label_entry.cget("values")), ("Ex 1", "Ex 2"))
|
||||
self.panel.label_var.set("Ex 1")
|
||||
self.assertEqual(self.panel.score_var.get(), "2")
|
||||
self.app.state_store.update_step("final_score", status="success")
|
||||
self.panel.score_var.set("3,5")
|
||||
self.panel.set_button.invoke()
|
||||
self.app.update()
|
||||
self.assertIs(self.app.score_editor_panel, self.panel)
|
||||
self.assertEqual(self.panel.id_var.get(), "1")
|
||||
self.assertEqual(read_json(self.path), {"Ex 1": "3.5", "Ex 2": "4"})
|
||||
self.assertEqual(self.app.state_store.step("final_score")["status"], "stale")
|
||||
self.panel.label_var.set("Ex 2")
|
||||
self.assertEqual(self.panel.score_var.get(), "4")
|
||||
self.panel.id_var.set("2")
|
||||
self.assertEqual(self.panel.score_var.get(), "")
|
||||
self.assertEqual(str(self.panel.set_button.cget("state")), "disabled")
|
||||
|
||||
def test_invalid_score_shows_error_and_running_disables_edits(self):
|
||||
self.panel.id_var.set("01")
|
||||
self.panel.label_var.set("Ex 1")
|
||||
self.panel.score_var.set("bad")
|
||||
with patch("copienator_gui.scores.messagebox.showerror") as error:
|
||||
self.panel.set_button.invoke()
|
||||
error.assert_called_once()
|
||||
self.assertEqual(read_json(self.path)["Ex 1"], "2")
|
||||
self.panel.set_busy(True)
|
||||
self.assertEqual(str(self.panel.set_button.cget("state")), "disabled")
|
||||
|
||||
def test_skipping_without_changes_preserves_downstream_status(self):
|
||||
self.app.state_store.update_step("final_score", status="success")
|
||||
self.app._skip_step()
|
||||
self.assertEqual(self.app.state_store.step("edit_scores")["status"], "skipped")
|
||||
self.assertEqual(self.app.state_store.step("final_score")["status"], "success")
|
||||
Reference in new issue
Block a user