Allow signaling issues in label verification
This commit is contained in:
1 parent
3754c0c11a
commit
9925362352
21 files changed
+1347
-36
No files matched your search
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import CliError, EvaluationWorkspace, atomic_write_json, read_json
|
||||
from copienator.annotation_modifications import (
|
||||
APPLIED,
|
||||
BASELINE,
|
||||
DRAFT,
|
||||
body_image,
|
||||
read_instructions,
|
||||
stack,
|
||||
stage_baseline,
|
||||
)
|
||||
from copienator.commands import modify_annotations
|
||||
from copienator.commands import reading_annotations as standard
|
||||
from copienator.commands import reading_grouped_annotations as grouped
|
||||
from copienator.filesystem import staged_files
|
||||
from copienator.return_answers import publish_answer_returns
|
||||
|
||||
|
||||
class ModificationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
(self.root / "Copies" / "Copie43").mkdir(parents=True)
|
||||
(self.root / "labels").write_text("Ex 1\nEx 2\nEx 3\nEmpty\n")
|
||||
self.workspace = EvaluationWorkspace(self.root)
|
||||
self.mode = "Bnot"
|
||||
self.images = {
|
||||
"Ex 1": stack([Image.new("RGB", (100, 20), "red"),
|
||||
Image.new("RGB", (100, 40), "blue"),
|
||||
Image.new("RGB", (100, 60), "green")]),
|
||||
"Ex 2": stack([Image.new("RGB", (120, 30), "yellow"),
|
||||
Image.new("RGB", (120, 50), "purple")]),
|
||||
"Ex 3": stack([Image.new("RGB", (100, 10), "orange"),
|
||||
Image.new("RGB", (100, 30), "cyan")]),
|
||||
}
|
||||
self.heights = {"Ex 1": 20, "Ex 2": 30, "Ex 3": 10}
|
||||
self.scores = {"Ex 1": "3", "Ex 2": "4", "Ex 3": "4", "Empty": ""}
|
||||
self.info = {label: {"present": label != "Empty", "not_empty": label != "Empty",
|
||||
"touched": False, "score": score}
|
||||
for label, score in self.scores.items()}
|
||||
self.seed()
|
||||
|
||||
@property
|
||||
def output(self):
|
||||
return self.root / self.mode / "Copie43"
|
||||
|
||||
def seed(self):
|
||||
self.output.mkdir(parents=True, exist_ok=True)
|
||||
with staged_files(self.output) as staging:
|
||||
for label, image in self.images.items():
|
||||
image.save(staging / f"{label}.jpg")
|
||||
atomic_write_json(staging / "score.json", self.scores)
|
||||
atomic_write_json(staging / "info.json", self.info)
|
||||
stage_baseline(self.root, self.mode, self.output, staging, self.images,
|
||||
self.heights, self.info, ["Ex 1"], self.scores)
|
||||
|
||||
def apply(self, *lines):
|
||||
(self.root / DRAFT).write_text(f"### Source: {self.mode}\n" + "\n".join(lines))
|
||||
return modify_annotations.run(self.workspace, annotation_dir=self.mode)
|
||||
|
||||
def image(self, label):
|
||||
with Image.open(self.output / f"{label}.jpg") as image:
|
||||
return image.convert("RGB").copy()
|
||||
|
||||
def assert_color(self, image, xy, expected):
|
||||
expected = Image.new("RGB", (1, 1), expected).getpixel((0, 0))
|
||||
self.assertTrue(all(abs(a-b) < 12 for a, b in zip(image.getpixel(xy), expected)),
|
||||
(image.getpixel(xy), expected))
|
||||
|
||||
def test_cut_uses_body_height_preserves_own_headers_and_scores(self):
|
||||
self.apply("Copie43 Ex 1 c{40}2> |Ex 2")
|
||||
source, target = self.image("Ex 1"), self.image("Ex 2")
|
||||
self.assertEqual(source.size, (100, 80))
|
||||
self.assertEqual(target.size, (120, 120))
|
||||
self.assert_color(source, (50, 5), "red")
|
||||
self.assert_color(source, (50, 45), "green")
|
||||
self.assert_color(target, (50, 10), "yellow")
|
||||
self.assert_color(target, (50, 45), "blue")
|
||||
self.assert_color(target, (50, 90), "purple")
|
||||
self.assertEqual(read_json(self.output / "score.json"), self.scores)
|
||||
self.assertEqual(body_image(self.output, "Ex 1").size, (100, 100))
|
||||
|
||||
def test_repeated_application_edit_and_removal_rebuild_from_baseline(self):
|
||||
line = "Copie43 Ex 1 c{40}1> Ex 2|"
|
||||
self.apply(line)
|
||||
first = {p.name: p.read_bytes() for p in self.output.glob("*.jpg")}
|
||||
self.apply(line)
|
||||
self.assertEqual(first, {p.name: p.read_bytes() for p in self.output.glob("*.jpg")})
|
||||
self.apply("Copie43 Ex 1 c{60}1> Ex 2|")
|
||||
self.assertEqual(self.image("Ex 2").height, 120)
|
||||
self.apply()
|
||||
self.assertEqual(self.image("Ex 1").size, self.images["Ex 1"].size)
|
||||
self.assertEqual(self.image("Ex 2").size, self.images["Ex 2"].size)
|
||||
self.assertEqual(read_json(self.output / "info.json"), self.info)
|
||||
|
||||
def test_empty_target_order_multiple_sources_and_header_creation(self):
|
||||
self.apply("Copie43 Ex 1 c{40}1> Empty|", "Copie43 Ex 3 -> |Empty")
|
||||
image = self.image("Empty")
|
||||
self.assertEqual(image.height, 80 + 60 + 30)
|
||||
self.assert_color(image, (50, 90), "cyan")
|
||||
self.assert_color(image, (50, 140), "green")
|
||||
info = read_json(self.output / "info.json")["Empty"]
|
||||
self.assertTrue(info["present"] and info["not_empty"])
|
||||
self.assertEqual(info["score"], "")
|
||||
self.apply()
|
||||
self.assertFalse((self.output / "Empty.jpg").exists())
|
||||
self.assertFalse(read_json(self.output / "info.json")["Empty"]["not_empty"])
|
||||
|
||||
def test_invalid_file_does_not_apply_valid_first_instruction(self):
|
||||
before = {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()}
|
||||
with self.assertRaises(CliError):
|
||||
self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 3 -> Unknown|")
|
||||
self.assertEqual(before, {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()})
|
||||
self.assertFalse((self.output.parent / APPLIED).exists())
|
||||
with self.assertRaises(CliError):
|
||||
self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 2 -> Ex 3|")
|
||||
with self.assertRaises(CliError):
|
||||
self.apply("Copie43 Ex 1 -> Ex 2|", "Copie43 Ex 1 -> Ex 3|")
|
||||
with self.assertRaises(CliError):
|
||||
read_instructions("### Source: BGnot\n", "Bnot")
|
||||
|
||||
def test_missing_baseline_requires_rereading_not_guessing_header(self):
|
||||
(self.output / BASELINE).unlink()
|
||||
with self.assertRaisesRegex(CliError, "Relancez"):
|
||||
self.apply("Copie43 Ex 1 -> Ex 2|")
|
||||
|
||||
def test_grouped_supplements_use_destination_label_and_force_inclusion(self):
|
||||
self.mode = "BGnot"
|
||||
self.seed()
|
||||
extras = Image.new("RGB", (120, 12), "black")
|
||||
with patch.object(grouped, "get_extra_pdfs_as_images", return_value=[extras]) as get_extra, \
|
||||
patch.object(grouped, "save_paginated_pdf") as save:
|
||||
self.apply("Copie43 Ex 1 c{40}2> Ex 2|")
|
||||
self.assertEqual([c.args[1] for c in get_extra.call_args_list], ["Ex 1", "Ex 2"])
|
||||
self.assertIs(get_extra.call_args_list[0].kwargs["seen_contexts"],
|
||||
get_extra.call_args_list[1].kwargs["seen_contexts"])
|
||||
groups = save.call_args.args[0]
|
||||
self.assertEqual([group[0].height for group in groups], [12, 12])
|
||||
self.assertEqual(groups[1][1].height, 120)
|
||||
self.assertEqual(self.image("Ex 2").height, 120) # supplement never in answer
|
||||
self.assertTrue(read_json(self.output / "info.json")["Ex 2"]["touched"])
|
||||
|
||||
def test_rereading_replays_applied_not_draft_and_refreshes_baseline(self):
|
||||
self.apply("Copie43 Ex 1 c{40}1> Ex 2|")
|
||||
(self.root / DRAFT).write_text("broken unapplied draft")
|
||||
self.images["Ex 1"] = stack([Image.new("RGB", (100, 20), "red"),
|
||||
Image.new("RGB", (100, 100), "cyan")])
|
||||
self.seed()
|
||||
self.assertEqual(self.image("Ex 2").height, 140)
|
||||
self.assert_color(self.image("Ex 2"), (50, 110), "cyan")
|
||||
self.assertEqual(body_image(self.output, "Ex 1").getpixel((20, 20)), (0, 255, 255))
|
||||
|
||||
def test_transaction_rolls_back_all_copies_and_applied_file(self):
|
||||
import shutil
|
||||
(self.root / "Copies/Copie44").mkdir()
|
||||
second = self.output.parent / "Copie44"
|
||||
shutil.copytree(self.output, second)
|
||||
self.apply("Copie43 Ex 1 -> Ex 2|")
|
||||
before = {str(p.relative_to(self.output.parent)): p.read_bytes()
|
||||
for p in self.output.parent.rglob("*") if p.is_file()}
|
||||
original = Path.replace
|
||||
def fail_once(path, target):
|
||||
if path.parent.name == "Copie44" and path.name == "Concat.jpg" and ".annotation-edits-" in str(path):
|
||||
raise OSError("simulated publish failure")
|
||||
return original(path, target)
|
||||
with patch.object(Path, "replace", fail_once), self.assertRaises(OSError):
|
||||
self.apply("Copie43 Ex 1 c{40}1> Ex 2|", "Copie44 Ex 1 -> Ex 2|")
|
||||
after = {str(p.relative_to(self.output.parent)): p.read_bytes()
|
||||
for p in self.output.parent.rglob("*") if p.is_file()}
|
||||
self.assertEqual(before, after)
|
||||
|
||||
def test_individual_returns_contain_modified_body_with_original_target_header(self):
|
||||
from copienator import configuration
|
||||
self.apply("Copie43 Ex 1 c{40}2> |Ex 2")
|
||||
destination = self.root / "A Rendre/Student (43)"
|
||||
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
|
||||
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False,
|
||||
RETURN_ANSWERS_SOLUTION=False):
|
||||
publish_answer_returns(self.root, self.output, destination)
|
||||
with Image.open(destination / "answers/Ex 2.jpg") as image:
|
||||
self.assertEqual(image.size, (120, 120))
|
||||
self.assert_color(image, (50, 10), "yellow")
|
||||
self.assert_color(image, (50, 45), "blue")
|
||||
|
||||
def test_empty_everything_clears_old_combined_outputs(self):
|
||||
self.apply("Copie43 Ex 1 xs Empty|", "Copie43 Ex 2 xs Empty|", "Copie43 Ex 3 xs Empty|")
|
||||
self.assertFalse((self.output / "Concat.jpg").exists())
|
||||
self.assertFalse((self.output / "Concat_F.jpg").exists())
|
||||
self.assertFalse(any(entry["not_empty"] for entry in read_json(self.output / "info.json").values()))
|
||||
|
||||
def test_both_readers_capture_reviewed_header_and_handwriting_separately(self):
|
||||
from copy import deepcopy
|
||||
|
||||
from copienator.commands import annotating
|
||||
for mode in ("BGnot", "Bnot"):
|
||||
with self.subTest(mode=mode):
|
||||
self.mode = mode
|
||||
self.seed()
|
||||
answer = self.root / "answer.pdf"
|
||||
answer.touch()
|
||||
data = {"43": {"Ex 1": {"pdf_path": answer, "coordinates": (0, 0),
|
||||
"result": {"score": 3, "feedback": [{"text": "delete me"}, {"text": "keep me"}]}}}}
|
||||
notes = Image.new("RGBA", (100, 110), (0, 0, 0, 0))
|
||||
notes.paste((255, 0, 255, 255), (20, 40, 40, 60)) # body ink, old header = 10
|
||||
notes.paste((0, 0, 0, 255), (20, 2, 40, 7)) # header ink stays there
|
||||
actions = [{"label": "Ex 1", "type": "del_global", "index": 0}]
|
||||
atomic_write_json(self.output / "bnote.json", {"images": [
|
||||
{"label": "Ex 1", "hmin": 0, "hmax": 110, "header_height": 10}]})
|
||||
def compose(_base, _label, result, *_args, **_kwargs):
|
||||
self.assertTrue(result["feedback"][0]["to_delete"])
|
||||
self.assertNotIn("to_delete", result["feedback"][1])
|
||||
return self.images["Ex 1"].copy(), 20
|
||||
with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), \
|
||||
patch.object(annotating, "compose_label_image", side_effect=compose), \
|
||||
patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]):
|
||||
if mode == "Bnot":
|
||||
status = standard.apply_actions_and_regenerate(
|
||||
self.workspace, deepcopy(data), "43", actions, notes, list(self.scores))
|
||||
else:
|
||||
status, _ = grouped.apply_actions_and_regenerate_grouped(
|
||||
self.workspace, deepcopy(data), "43", actions,
|
||||
{"Ex 1": {"img": notes, "old_header_h": 10}}, list(self.scores))
|
||||
self.assertEqual(status, 0)
|
||||
body = body_image(self.output, "Ex 1")
|
||||
self.assertEqual(body.size, (100, 100))
|
||||
self.assertEqual(body.getpixel((25, 35)), (255, 0, 255))
|
||||
self.assertEqual(body.getpixel((25, 5)), (0, 0, 255))
|
||||
self.apply("Copie43 Ex 1 c{40}2> Empty|")
|
||||
self.assert_color(self.image("Empty"), (25, 115), "magenta")
|
||||
self.assert_color(self.image("Ex 1"), (25, 4), "black")
|
||||
|
||||
def test_selective_redo_does_not_bake_in_or_duplicate_previous_transfers(self):
|
||||
from copy import deepcopy
|
||||
|
||||
from copienator.commands import annotating
|
||||
self.mode = "BGnot"
|
||||
self.seed()
|
||||
self.apply("Copie43 Ex 1 x> Ex 2|")
|
||||
self.assertFalse((self.output / "Ex 1.jpg").exists())
|
||||
before = self.image("Ex 2").size
|
||||
answer = self.root / "answer.pdf"
|
||||
answer.touch()
|
||||
data = {"43": {label: {"result": {"score": self.scores[label], "feedback": []},
|
||||
"pdf_path": answer, "coordinates": (0, 0)}
|
||||
for label in self.images}}
|
||||
with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), \
|
||||
patch.object(annotating, "compose_label_image", return_value=(self.images["Ex 3"], 10)), \
|
||||
patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]):
|
||||
for _ in range(2):
|
||||
status, _ = grouped.apply_actions_and_regenerate_grouped(
|
||||
self.workspace, deepcopy(data), "43", [], {}, list(self.scores),
|
||||
annotation_dir="BGnot", selected_labels={"Ex 3"})
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(self.image("Ex 2").size, before)
|
||||
self.assertFalse((self.output / "Ex 1.jpg").exists())
|
||||
self.assertEqual(body_image(self.output, "Ex 1").size, (100, 100))
|
||||
@@ -0,0 +1,79 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import atomic_write_json, read_json
|
||||
from copienator.commands import annotating_by_label as grouped
|
||||
from copienator.utils import natural_key
|
||||
|
||||
|
||||
class GroupedNamingTests(unittest.TestCase):
|
||||
def test_dsi_question_groups_have_short_unique_ordered_prefixes(self):
|
||||
groups = [
|
||||
["Ex 1", "Ex 2", "Ex 3"],
|
||||
["Ex 4 : 1)", "Ex 4 : 2)"],
|
||||
["I)1)", "I)2)"],
|
||||
["I)3)", "I)4)"],
|
||||
["I)5)", "I)6)", "I)7)"],
|
||||
["I)8)", "I)9)"],
|
||||
["I)10)", "I)11)", "I)12)"],
|
||||
["I)13)"],
|
||||
["I)14)", "I)15)", "I)16)"],
|
||||
["II)1)", "II)2)"],
|
||||
["II)3)a)", "II)3)b)"],
|
||||
["II)4)", "II)5)", "II)6)", "II)7)"],
|
||||
["II)8)", "II)9)"],
|
||||
["II)10)"],
|
||||
]
|
||||
named = grouped._unique_prefixes(groups)
|
||||
prefixes = [prefix for prefix, _labels in named]
|
||||
self.assertEqual(len(prefixes), len(set(prefixes)))
|
||||
self.assertEqual(prefixes, sorted(prefixes, key=natural_key))
|
||||
self.assertEqual(prefixes, [
|
||||
"Ex", "Ex-2", "I)", "I)-2", "I)-3", "I)-4", "I)-5",
|
||||
"I)-6", "I)-7", "II)", "II)-2", "II)-3", "II)-4", "II)-5",
|
||||
])
|
||||
self.assertEqual([labels for _prefix, labels in named], groups)
|
||||
|
||||
def test_resume_preserves_legacy_names_and_appends_only_missing_answers(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary)
|
||||
# The old naming bug put distinct question groups under I)-2.
|
||||
for number, label in enumerate(["I)3)", "I)10)", "II)1)"], start=1):
|
||||
folder = output / f"I)-2 G{number}"
|
||||
folder.mkdir()
|
||||
atomic_write_json(folder / "bnote.json", {
|
||||
"images": [{"id": "01", "label": label}],
|
||||
})
|
||||
(folder / "Concat.pdf").write_bytes(b"reviewed PDF")
|
||||
before = {p: p.read_bytes() for p in output.rglob("*") if p.is_file()}
|
||||
groups = [["I)1)", "I)2)"], ["I)3)", "I)4)"],
|
||||
["I)10)", "I)11)"], ["II)1)", "II)2)"]]
|
||||
data = {"01": {label: {} for label in ["I)3)", "I)10)", "II)1)"]}}
|
||||
with patch.object(grouped, "render_item") as render:
|
||||
self.assertEqual(grouped._generate_groups(
|
||||
output, data, groups, resume=True,
|
||||
), (0, False))
|
||||
render.assert_not_called()
|
||||
self.assertEqual(before, {
|
||||
p: p.read_bytes() for p in output.rglob("*") if p.is_file()
|
||||
})
|
||||
|
||||
data["02"] = {"I)3)": {}}
|
||||
with patch.object(grouped, "render_item", return_value=(
|
||||
"02", "I)3)", Image.new("RGB", (100, 100), "white"), 0, [],
|
||||
)) as render:
|
||||
self.assertEqual(grouped._generate_groups(
|
||||
output, data, groups, resume=True,
|
||||
), (1, False))
|
||||
render.assert_called_once_with(("02", "I)3)", {}))
|
||||
self.assertEqual(read_json(output / "I)-2 G4/bnote.json")["images"][0]["id"], "02")
|
||||
for path, content in before.items():
|
||||
self.assertEqual(path.read_bytes(), content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import atomic_write_json
|
||||
from copienator.annotation_modifications import DRAFT, stage_baseline
|
||||
from copienator.filesystem import staged_files
|
||||
from copienator_gui.app import CopienatorApp
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk tests require a display")
|
||||
class AnnotationModificationGuiTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
(self.root / "Copies/Copie43").mkdir(parents=True)
|
||||
(self.root / "labels").write_text("Ex 1\nEx 2\n")
|
||||
self.output = self.root / "Bnot/Copie43"
|
||||
images = {"Ex 1": Image.new("RGB", (100, 120), "blue")}
|
||||
scores = {"Ex 1": "3", "Ex 2": ""}
|
||||
info = {label: {"present": label == "Ex 1", "not_empty": label == "Ex 1",
|
||||
"touched": False, "score": score} for label, score in scores.items()}
|
||||
with staged_files(self.output) as staging:
|
||||
images["Ex 1"].save(staging / "Ex 1.jpg")
|
||||
atomic_write_json(staging / "score.json", scores)
|
||||
atomic_write_json(staging / "info.json", info)
|
||||
stage_baseline(self.root, "Bnot", self.output, staging, images, {"Ex 1": 20}, info, ["Ex 1"], scores)
|
||||
self.app = CopienatorApp(Path.cwd(), False, self.root)
|
||||
self.app.state_store.update_step("read_annotations", last_run_variant="standard")
|
||||
self.app.tree.selection_set("modify_annotations")
|
||||
self.app.update()
|
||||
self.panel = self.app.manual_panel
|
||||
|
||||
def tearDown(self):
|
||||
for callback in self.app.tk.splitlist(self.app.tk.call("after", "info")):
|
||||
self.app.after_cancel(callback)
|
||||
self.app.destroy()
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_add_validate_edit_reload_and_cut_only_body(self):
|
||||
from copienator.annotation_modifications import read_instructions
|
||||
self.assertEqual(self.app.arg_vars["annotation_dir"].get(), "Bnot")
|
||||
self.assertEqual(self.app.run_button.cget("text"), "Make modifications")
|
||||
self.panel.copy_var.set("43")
|
||||
self.panel.source_var.set("Ex 1")
|
||||
self.panel.target_var.set("Ex 2")
|
||||
self.panel.add_instruction()
|
||||
path = self.root / DRAFT
|
||||
self.assertIn("Copie43 Ex 1 -> Ex 2|", path.read_text())
|
||||
item = read_instructions(path.read_text(), "Bnot")[0]
|
||||
with patch("copienator_gui.annotation_modifications.CutHelper") as helper:
|
||||
self.panel.open_cut(self.root, item)
|
||||
self.assertEqual(helper.call_args.args[1].size, (100, 100))
|
||||
helper.call_args.args[2]("c{40}2>")
|
||||
self.assertEqual(self.panel.cut_result.get(), "Copie43 Ex 1 c{40}2> Ex 2|")
|
||||
with patch("copienator_gui.annotation_modifications.messagebox.showerror") as error:
|
||||
self.panel.add_instruction() # duplicate source
|
||||
error.assert_called_once()
|
||||
path.write_text("bad instruction")
|
||||
self.panel.reload()
|
||||
with patch("copienator_gui.annotation_modifications.open_path") as editor:
|
||||
self.panel.open_editor() # malformed files must remain editable
|
||||
editor.assert_called_once_with(path)
|
||||
|
||||
def test_workflow_is_between_reader_and_publisher_and_builds_command(self):
|
||||
steps = list(self.app.step_by_id)
|
||||
self.assertLess(steps.index("read_annotations"), steps.index("modify_annotations"))
|
||||
self.assertLess(steps.index("modify_annotations"), steps.index("giving_names"))
|
||||
command = self.app._make_command()
|
||||
self.assertIn("modify-annotations", command)
|
||||
self.assertEqual(command[-2:], ["--annotation-dir", "Bnot"])
|
||||
@@ -976,6 +976,9 @@ class StandardCliTests(unittest.TestCase):
|
||||
(evaluation / "Copies").mkdir(parents=True)
|
||||
viewer = module.ImageViewer.__new__(module.ImageViewer)
|
||||
viewer.base_dir = evaluation
|
||||
viewer.workspace = EvaluationWorkspace(evaluation)
|
||||
viewer.history = [(None, None, {"part": 1, "schema": {"columns_per_file": [1]}},
|
||||
"Copie01", None)]
|
||||
viewer.active_copie_name = "Copie01"
|
||||
viewer.accumulated_results = {
|
||||
"name": "Test",
|
||||
@@ -1121,6 +1124,7 @@ class StandardCliTests(unittest.TestCase):
|
||||
|
||||
root.bind.assert_any_call("p", viewer.on_previous)
|
||||
root.bind.assert_any_call("P", viewer.on_previous)
|
||||
root.bind.assert_any_call("!", viewer.on_skip)
|
||||
|
||||
def test_plotting_validates_plain_and_directional_labels(self) -> None:
|
||||
module = self.modules["plotting"]
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import os
|
||||
import tempfile
|
||||
import tkinter as tk
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, read_json
|
||||
from copienator.commands import cutleft, gemini_for_labels, plotting
|
||||
from copienator.copy_errors import clear_copy_error, copy_errors, mark_copy_error, marked_copy_paths
|
||||
|
||||
|
||||
class LabelCopyFlagTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.workspace = EvaluationWorkspace(Path(self.temp.name))
|
||||
self.workspace.copies_dir.mkdir()
|
||||
self.workspace.cutleft_dir.mkdir()
|
||||
self.pdf = self.workspace.copies_dir / 'Copie01.pdf'
|
||||
self.pdf.touch()
|
||||
|
||||
def flag(self, source):
|
||||
mark_copy_error(self.workspace, self.pdf, source, source=source)
|
||||
|
||||
def viewer(self, parts=2):
|
||||
viewer = plotting.ImageViewer.__new__(plotting.ImageViewer)
|
||||
viewer.workspace = self.workspace
|
||||
viewer.base_dir = self.workspace.root
|
||||
viewer.active_copie_name = 'Copie01'
|
||||
viewer.accumulated_results = {'name': 'Student', 'list': []}
|
||||
viewer.current_meta = {'copie': 'Copie01', 'part': 1,
|
||||
'schema': {'columns_per_file': [1] * parts}}
|
||||
viewer.current_json_path = self.pdf.with_name('Copie01_01.json')
|
||||
atomic_write_json(viewer.current_json_path, {'name': 'Student', 'list': []})
|
||||
viewer.valid_labels = set()
|
||||
viewer.history = []
|
||||
viewer.forward_stack = []
|
||||
viewer.skipped_copies = set()
|
||||
viewer.current_pil_image = Image.new('RGB', (20, 20), 'white')
|
||||
viewer.is_viewing = True
|
||||
viewer.failed = False
|
||||
viewer.label = Mock()
|
||||
viewer.root = Mock()
|
||||
viewer.image_queue = Queue()
|
||||
return viewer
|
||||
|
||||
def test_legacy_flags_migrate_and_steps_clear_independently(self):
|
||||
atomic_write_json(self.workspace.metadata_dir / 'copy_errors.json',
|
||||
{self.pdf.name: 'Legacy cropping problem'})
|
||||
self.flag('plotting')
|
||||
clear_copy_error(self.workspace, self.pdf)
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
|
||||
self.flag('cutleft')
|
||||
clear_copy_error(self.workspace, self.pdf, source='plotting')
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'cutleft'})
|
||||
clear_copy_error(self.workspace, self.pdf)
|
||||
self.assertEqual(marked_copy_paths(self.workspace), [])
|
||||
|
||||
def test_successful_crop_preserves_review_flag_and_selection(self):
|
||||
self.flag('cutleft')
|
||||
self.flag('plotting')
|
||||
image = Image.new('RGB', (20, 20), 'white')
|
||||
with patch.object(cutleft, 'process_single_pdf', return_value=(image, [image], {})), \
|
||||
patch.object(cutleft, 'save_results'):
|
||||
self.assertEqual(cutleft.run(self.workspace, self.workspace.root, marked=True,
|
||||
fullpage=True, no_visual_check=True), ExitCode.SUCCESS)
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
|
||||
self.assertEqual(marked_copy_paths(self.workspace), [self.pdf])
|
||||
|
||||
def test_full_review_clears_only_review_flag_after_save(self):
|
||||
self.flag('cutleft')
|
||||
self.flag('plotting')
|
||||
viewer = self.viewer()
|
||||
viewer.on_enter(None)
|
||||
viewer.current_meta = dict(viewer.current_meta, part=2)
|
||||
viewer.is_viewing = True
|
||||
viewer.on_enter(None)
|
||||
with patch.object(plotting, 'atomic_write_json', side_effect=OSError('disk full')):
|
||||
with self.assertRaises(OSError):
|
||||
viewer.save_current_batch()
|
||||
self.assertIn('plotting', copy_errors(self.workspace)[self.pdf.name])
|
||||
viewer.save_current_batch()
|
||||
self.assertEqual(read_json(self.pdf.with_suffix('.json')), {'name': 'Student', 'list': []})
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'cutleft'})
|
||||
|
||||
def test_incomplete_review_preserves_previous_aggregate_and_flag(self):
|
||||
self.flag('plotting')
|
||||
old = {'name': 'Old', 'list': [{'label': 'Ex 1'}]}
|
||||
atomic_write_json(self.pdf.with_suffix('.json'), old)
|
||||
viewer = self.viewer()
|
||||
viewer.on_enter(None)
|
||||
viewer.save_current_batch()
|
||||
self.assertEqual(read_json(self.pdf.with_suffix('.json')), old)
|
||||
self.assertIn(self.pdf.name, copy_errors(self.workspace))
|
||||
self.assertTrue(viewer.failed)
|
||||
|
||||
def test_flag_skips_remaining_copy_parts_without_saving_partial_data(self):
|
||||
self.flag('cutleft')
|
||||
old = {'name': 'Old', 'list': []}
|
||||
atomic_write_json(self.pdf.with_suffix('.json'), old)
|
||||
viewer = self.viewer(parts=3)
|
||||
viewer.on_enter(None)
|
||||
viewer.is_viewing = True
|
||||
viewer.current_meta = dict(viewer.current_meta, part=2)
|
||||
viewer.on_skip()
|
||||
viewer.save_current_batch()
|
||||
self.assertEqual(read_json(self.pdf.with_suffix('.json')), old)
|
||||
self.assertEqual(set(read_json(self.workspace.metadata_dir / 'copy_errors.json')[self.pdf.name]),
|
||||
{'cutleft', 'plotting'})
|
||||
viewer.image_queue.put((viewer.current_pil_image, viewer.current_json_path,
|
||||
dict(viewer.current_meta, part=3)))
|
||||
viewer.display_image = Mock()
|
||||
viewer.poll_queue()
|
||||
viewer.display_image.assert_not_called()
|
||||
self.assertFalse(viewer.is_viewing)
|
||||
|
||||
def test_detection_regenerates_only_flagged_copies_and_preserves_flags(self):
|
||||
self.flag('plotting')
|
||||
(self.workspace.copies_dir / 'Copie02.pdf').touch()
|
||||
for name in ('Copie01_01', 'Copie02_01'):
|
||||
(self.workspace.cutleft_dir / f'{name}.jpg').touch()
|
||||
atomic_write_json(self.workspace.copies_dir / f'{name}.json', {'name': 'Old', 'list': []})
|
||||
with patch.object(self.workspace.__class__, 'require_files'), \
|
||||
patch.object(gemini_for_labels, 'read_all_labels', return_value=[]), \
|
||||
patch.object(self.workspace.__class__, 'names_file', return_value=self.pdf), \
|
||||
patch.object(gemini_for_labels, 'process_copy_group', return_value=1) as process:
|
||||
status = gemini_for_labels.run(self.workspace, [self.workspace.root], marked=True, client=Mock())
|
||||
self.assertEqual(status, ExitCode.SUCCESS)
|
||||
process.assert_called_once()
|
||||
self.assertEqual(process.call_args.args[1], 'Copie01')
|
||||
self.assertTrue(process.call_args.kwargs['overwrite'])
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
|
||||
|
||||
def test_visual_review_selects_only_flagged_copy_images(self):
|
||||
self.flag('plotting')
|
||||
for name in ('Copie01_01', 'Copie01_02', 'Copie02_01'):
|
||||
(self.workspace.cutleft_dir / f'{name}.jpg').touch()
|
||||
with patch.object(self.workspace.__class__, 'require_files'), \
|
||||
patch.object(plotting, 'read_all_labels', return_value=[]), \
|
||||
patch.object(plotting.threading, 'Thread') as worker, \
|
||||
patch.object(plotting.tk, 'Tk'), \
|
||||
patch.object(plotting, 'ImageViewer') as viewer:
|
||||
viewer.return_value.failed = False
|
||||
self.assertEqual(plotting.run(self.workspace, self.workspace.root, marked=True),
|
||||
ExitCode.SUCCESS)
|
||||
selected = worker.call_args.kwargs['args'][1]
|
||||
self.assertEqual([image.name for image in selected], ['Copie01_01.jpg', 'Copie01_02.jpg'])
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
|
||||
|
||||
def test_closing_review_preserves_flags_and_does_not_save_partial_result(self):
|
||||
self.flag('plotting')
|
||||
viewer = self.viewer()
|
||||
viewer.on_enter(None)
|
||||
viewer.close()
|
||||
self.assertEqual(copy_errors(self.workspace), {self.pdf.name: 'plotting'})
|
||||
self.assertFalse(self.pdf.with_suffix('.json').exists())
|
||||
|
||||
@unittest.skipUnless(os.environ.get('DISPLAY'), 'Tk requires a display')
|
||||
def test_exclamation_key_flags_current_copy_in_real_viewer(self):
|
||||
root = tk.Tk()
|
||||
try:
|
||||
image_queue = Queue()
|
||||
image_queue.put((Image.new('RGB', (100, 100), 'white'),
|
||||
self.pdf.with_name('Copie01_01.json'),
|
||||
{'copie': 'Copie01', 'part': 1, 'name': 'Student',
|
||||
'schema': {'columns_per_file': [1, 1]}}))
|
||||
viewer = plotting.ImageViewer(root, self.workspace, set(), image_queue)
|
||||
root.update()
|
||||
root.focus_force()
|
||||
root.update()
|
||||
root.event_generate('<KeyPress>', keysym='exclam')
|
||||
root.update()
|
||||
self.assertEqual(viewer.skipped_copies, {'Copie01'})
|
||||
self.assertFalse(viewer.is_viewing)
|
||||
self.assertIn(self.pdf.name, copy_errors(self.workspace))
|
||||
finally:
|
||||
for callback in root.tk.splitlist(root.tk.call('after', 'info')):
|
||||
root.after_cancel(callback)
|
||||
root.destroy()
|
||||
|
||||
def test_empty_marked_selection_does_not_process_whole_evaluation(self):
|
||||
with patch.object(self.workspace.__class__, 'require_files'), \
|
||||
patch.object(gemini_for_labels, 'selected_images') as select:
|
||||
self.assertEqual(gemini_for_labels.run(self.workspace, [self.workspace.root], marked=True),
|
||||
ExitCode.SUCCESS)
|
||||
select.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -43,7 +43,7 @@ class LabelTimeoutTests(unittest.TestCase):
|
||||
self.assertEqual(labels.main([str(self.workspace.root), *arguments]), 0)
|
||||
run.assert_called_once_with(
|
||||
unittest.mock.ANY, [self.workspace.root],
|
||||
overwrite=False, max_workers=expected,
|
||||
overwrite=False, marked=False, max_workers=expected,
|
||||
)
|
||||
|
||||
def test_selected_concurrency_controls_executor(self):
|
||||
|
||||
@@ -233,12 +233,13 @@ class MarkedCopiesGuiTests(unittest.TestCase):
|
||||
self.assertEqual(copy_errors(self.workspace), {})
|
||||
self.assertTrue((self.workspace.cutleft_dir / "Copie01_01.jpg").exists())
|
||||
|
||||
def test_both_buttons_run_marked_and_single_copy_target_clears_filter(self):
|
||||
def test_all_marked_buttons_and_single_copy_target_clears_filter(self):
|
||||
mark_copy_error(self.workspace, self.files[1], "Review needed")
|
||||
app = CopienatorApp(Path.cwd(), False, self.workspace.root)
|
||||
try:
|
||||
app.update()
|
||||
for ident, command in (("page_splitter", "page-split"), ("cutleft", "crop-labels")):
|
||||
for ident, command in (("page_splitter", "page-split"), ("cutleft", "crop-labels"),
|
||||
("labels", "labels"), ("plotting", "review-labels")):
|
||||
app.tree.selection_set(ident)
|
||||
app.update()
|
||||
self.assertIn("(1)", app.marked_copies_button.cget("text"))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from copienator.commands.reading_grouped_annotations import (
|
||||
context_content_key, get_extra_pdfs_as_images, save_paginated_pdf,
|
||||
)
|
||||
|
||||
|
||||
class PdfSupplementTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
(self.root / "Text2").mkdir()
|
||||
(self.root / "Sol2").mkdir()
|
||||
self.labels = ["Q1", "Q2", "Q3"]
|
||||
self.rendered = []
|
||||
|
||||
def render(path):
|
||||
self.rendered.append(Path(path).name)
|
||||
return Image.new("RGB", (100, 30), "white"), 30, 100
|
||||
|
||||
self.renderer = SimpleNamespace(make_base_image=render)
|
||||
|
||||
def pdf(self, folder, name, text):
|
||||
path = self.root / folder / name
|
||||
document = canvas.Canvas(str(path))
|
||||
document.drawString(20, 800, text)
|
||||
document.save()
|
||||
return path
|
||||
|
||||
def extras(self, label, seen):
|
||||
return get_extra_pdfs_as_images(
|
||||
self.root, label, self.renderer, self.labels, seen_contexts=seen,
|
||||
)
|
||||
|
||||
def test_content_deduplication_across_ranges_and_copy_reset(self):
|
||||
self.pdf("Text2", "CTXT Q1 -> Q2.pdf", "Bob collects coupons: N >= 1")
|
||||
self.pdf("Text2", "CTXT Q2 -> Q3.pdf", "Bob collects coupons: N >= 1")
|
||||
self.pdf("Text2", "CTXT Q2 -> Q2.pdf", "Bob collects coupons: N >= 2")
|
||||
seen = set()
|
||||
self.assertEqual(len(self.extras("Q1", seen)), 1)
|
||||
self.assertEqual(len(self.extras("Q2", seen)), 1)
|
||||
self.assertEqual(len(self.extras("Q3", seen)), 0)
|
||||
self.assertEqual(len(seen), 2)
|
||||
self.assertEqual(len(self.extras("Q3", set())), 1)
|
||||
|
||||
def test_first_retained_question_gets_context_and_semantic_borders(self):
|
||||
self.pdf("Text2", "CTXT Q1 -> Q3.pdf", "Context")
|
||||
self.pdf("Text2", "CTXT Q2 -> Q3.pdf", "Other context")
|
||||
self.pdf("Text2", "Q2.pdf", "Question")
|
||||
self.pdf("Sol2", "Q2.pdf", "Solution")
|
||||
# Q1 was filtered out; it never consumes the shared context.
|
||||
images = self.extras("Q2", set())
|
||||
self.assertEqual([i.getpixel((0, 0)) for i in images],
|
||||
[(255, 255, 255), (255, 255, 255), (0, 0, 0), (0, 0, 255)])
|
||||
self.assertTrue(all(i.getpixel((50, 15)) == (255, 255, 255) for i in images))
|
||||
|
||||
def test_missing_question_does_not_shift_solution_border(self):
|
||||
self.pdf("Text2", "CTXT Q1 -> Q3.pdf", "Context")
|
||||
self.pdf("Sol2", "Q2.pdf", "Solution")
|
||||
images = self.extras("Q2", set())
|
||||
self.assertEqual([i.getpixel((0, 0)) for i in images],
|
||||
[(255, 255, 255), (0, 0, 255)])
|
||||
|
||||
def test_unrendered_context_is_not_consumed(self):
|
||||
self.pdf("Text2", "CTXT Q1 -> Q3.pdf", "Context")
|
||||
seen = set()
|
||||
with patch.object(self.renderer, "make_base_image", return_value=(None, 0, 0)):
|
||||
self.assertEqual(self.extras("Q1", seen), [])
|
||||
self.assertFalse(seen)
|
||||
self.assertEqual(len(self.extras("Q2", seen)), 1)
|
||||
|
||||
def test_missing_solution_does_not_shift_border_to_student_answer(self):
|
||||
self.pdf("Text2", "Q2.pdf", "Question")
|
||||
images = self.extras("Q2", set())
|
||||
answer = Image.new("RGB", (100, 200), "white")
|
||||
captured = []
|
||||
|
||||
def save(page, *args, **kwargs):
|
||||
captured.extend([page.copy(), *kwargs['append_images']])
|
||||
|
||||
with patch.object(Image.Image, "save", save):
|
||||
save_paginated_pdf([images + [answer], [answer]], self.root / "out.pdf")
|
||||
self.assertEqual(len(captured), 2)
|
||||
self.assertEqual(captured[0].getpixel((11, 7)), (0, 0, 0))
|
||||
self.assertEqual(captured[0].getpixel((11, 37)), (255, 255, 255))
|
||||
self.assertEqual(captured[1].getpixel((11, 7)), (255, 255, 255))
|
||||
self.assertEqual(answer.getpixel((0, 0)), (255, 255, 255))
|
||||
|
||||
def test_empty_extracted_text_does_not_merge_distinct_contexts(self):
|
||||
paths = []
|
||||
for name, color in [("a.pdf", "red"), ("b.pdf", "blue")]:
|
||||
path = self.root / name
|
||||
document = canvas.Canvas(str(path))
|
||||
document.setFillColor(color)
|
||||
document.rect(20, 20, 100, 100, fill=1)
|
||||
document.save()
|
||||
paths.append(path)
|
||||
self.assertNotEqual(context_content_key(paths[0]), context_content_key(paths[1]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user