Allow signaling issues in label verification

This commit is contained in:
sebastien committed 2026-10-11 21:05:27 +02:00
1 parent 3754c0c11a
commit 9925362352
21 files changed
+1347 -36

No files matched your search

+265
View File
@@ -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))
+79
View File
@@ -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"])
+4
View File
@@ -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"]
+194
View File
@@ -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()
+1 -1
View File
@@ -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):
+3 -2
View File
@@ -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"))
+110
View File
@@ -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()