This commit is contained in:
2026-09-08 16:41:04 +02:00
parent db4ed2ef31
commit bf05272797
14 changed files with 725 additions and 75 deletions
+65 -1
View File
@@ -2,6 +2,8 @@ import os
import tempfile
import unittest
from pathlib import Path
from tkinter import ttk
from unittest.mock import patch
from copienator_gui.app import CopienatorApp
from copienator_gui.workflow import command_display
@@ -40,6 +42,16 @@ class GuiConvenienceTests(unittest.TestCase):
self.assertEqual(self.app.state_store.step("inputs")["status"], "ready")
self.assertIn("enonce.pdf", self.app.description_label.cget("text"))
def test_compact_evaluation_input_and_open_folder_button(self):
self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42)
self.assertEqual(
int(self.app.open_evaluation_button.grid_info()["column"]),
int(self.app.evaluation_entry.grid_info()["column"]) + 1,
)
with patch("copienator_gui.app.open_path") as opened:
self.app.open_evaluation_button.invoke()
opened.assert_called_once_with(self.evaluation)
def test_redo_targets_selected_copy_and_copies_runnable_command(self):
for folder in ("Copies", "Copies Originales"):
(self.evaluation / folder).mkdir()
@@ -48,7 +60,7 @@ class GuiConvenienceTests(unittest.TestCase):
self.app.tree.selection_set("page_splitter")
self.app.update()
self.app.copy_var.set("Copie02.pdf")
self.app._redo_selected_pages()
self.app._target_selected_copy()
command = self.app._make_command()
target = Path(command[-1])
self.assertEqual(target, self.evaluation / "Copies" / "Copie02.pdf")
@@ -59,6 +71,36 @@ class GuiConvenienceTests(unittest.TestCase):
self.app._target_all_pages()
self.assertEqual(self.app.arg_vars["target"].get(), self.app._evaluation_arg())
def test_label_detection_can_target_one_copy(self):
copies = self.evaluation / "Copies"
copies.mkdir()
for name in ("Copie01.pdf", "Copie02.pdf"):
(copies / name).touch()
self.app.tree.selection_set("labels")
self.app.update()
self.assertEqual(self.app.copy_var.get(), "Copie01.pdf")
self.app.copy_var.set("Copie02.pdf")
self.app._target_selected_copy()
command = self.app._make_command()
self.assertEqual(Path(command[-1]), copies / "Copie02.pdf")
def button_texts(widget):
return [
text
for child in widget.winfo_children()
for text in (
[child.cget("text")]
if isinstance(child, ttk.Button)
else button_texts(child)
)
]
controls = button_texts(self.app.form)
self.assertIn("Cibler la copie sélectionnée", controls)
self.assertIn("Cibler tout le dossier", controls)
def test_console_selection_survives_output_and_is_read_only(self):
self.app._append_console("Première ligne\nDeuxième ligne\n")
self.app.console.tag_add("sel", "1.0", "1.end")
@@ -73,3 +115,25 @@ class GuiConvenienceTests(unittest.TestCase):
self.app._select_console_all()
self.app._copy_console_selection()
self.assertEqual(self.app.clipboard_get(), expected)
def test_validate_rename_preserves_files_and_downstream_status(self):
pdf = self.evaluation / "Copie01.pdf"
pdf.write_bytes(b"unchanged PDF")
self.app.state_store.update_step("rename", status="stale")
self.app.state_store.update_step("page_splitter", status="success")
self.app.tree.selection_set("rename")
self.app.update()
with patch.object(self.app.runner, "start") as start:
self.app.validate_rename_button.invoke()
start.assert_not_called()
self.assertEqual(self.app.state_store.step("rename")["status"], "success")
self.assertEqual(self.app.state_store.step("page_splitter")["status"], "success")
self.assertEqual(pdf.read_bytes(), b"unchanged PDF")
self.assertEqual(list(self.evaluation.glob("*.pdf")), [pdf])
self.app.state_store.update_step("rename", status="stale")
self.app.active_step_id = "page_splitter"
self.app._update_controls()
self.assertIn("disabled", self.app.validate_rename_button.state())
self.app._validate_rename_step()
self.assertEqual(self.app.state_store.step("rename")["status"], "stale")
self.app.active_step_id = None
+90 -17
View File
@@ -10,7 +10,7 @@ import tempfile
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from contextlib import redirect_stderr
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
@@ -714,6 +714,8 @@ class StandardCliTests(unittest.TestCase):
copy_pdf.parent.mkdir(parents=True)
copy_pdf.write_bytes(b"pdf")
with patch.object(module, "ImageReviewer") as reviewer:
reviewer.return_value.completed = True
reviewer.return_value.had_errors = False
self.assertEqual(module.main([str(copy_pdf), "--fullpage"]), 0)
files, output_dir = reviewer.call_args.args[:2]
self.assertEqual(files, [copy_pdf])
@@ -1011,6 +1013,11 @@ class StandardCliTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory:
evaluation = Path(directory) / "Exam"
(evaluation / "Copies").mkdir(parents=True)
text_dir = evaluation / "Text"
text_dir.mkdir()
(text_dir / "Ex 1").write_text("Short match", encoding="utf-8")
closest_text = text_dir / "Ex 1 : 1)"
closest_text.write_text("Relevant statement text", encoding="utf-8")
image = evaluation / "Cutleft" / "Copie01_01.jpg"
image.parent.mkdir()
image.write_bytes(b"image")
@@ -1019,39 +1026,105 @@ class StandardCliTests(unittest.TestCase):
Mock(
text=(
'{"name":"Student","list":'
'[{"box_2d":[1,2,3,4],"label":"Wrong"}]}'
'[{"box_2d":[1,2,3,4],"label":"Ex 1 : l)"}]}'
)
),
Mock(
text=(
'{"name":"Student","list":'
'[{"box_2d":[1,2,3,4],"label":"Ex 1"}]}'
'[{"box_2d":[1,2,3,4],"label":"Ex 1 : 1)"}]}'
)
),
]
sleeps = []
module.process_copy_group(
EvaluationWorkspace(evaluation),
"Copie01",
[image],
client=client,
labels_text="Ex 1\n",
names_text="Student\n",
valid_labels={"Ex 1"},
valid_names={"Student", "Unknown", "Continued"},
overwrite=True,
sleep=sleeps.append,
target_interval=0,
)
output = io.StringIO()
with redirect_stdout(output):
module.process_copy_group(
EvaluationWorkspace(evaluation),
"Copie01",
[image],
client=client,
labels_text="Ex 1 : 1)\n",
names_text="Student\n",
valid_labels={"Ex 1 : 1)"},
valid_names={"Student", "Unknown", "Continued"},
overwrite=True,
sleep=sleeps.append,
target_interval=0,
)
self.assertEqual(client.models.generate_content.call_count, 2)
self.assertIn(10, sleeps)
retry_contents = client.models.generate_content.call_args_list[1].kwargs[
"contents"
]
retry_prompt = retry_contents[0].parts[1].text
self.assertIn('"Ex 1 : l)"', retry_prompt)
self.assertIn("CRITICAL RETRY CONSTRAINT: NEVER return", retry_prompt)
self.assertIn("Relevant statement text", retry_prompt)
self.assertIn("`Ex 1 : 1)`", retry_prompt)
self.assertIn(
"Retry context for Copie01_01.jpg: Text/Ex 1 : 1)",
output.getvalue(),
)
self.assertEqual(
read_json(evaluation / "Copies" / "Copie01_01.json")["list"][0][
"label"
],
"Ex 1",
"Ex 1 : 1)",
)
def test_label_detection_marks_a_thrice_repeated_unknown_label(self) -> None:
module = self.modules["gemini_for_labels"]
with tempfile.TemporaryDirectory() as directory:
evaluation = Path(directory) / "Exam"
(evaluation / "Copies").mkdir(parents=True)
(evaluation / "Text").mkdir()
(evaluation / "Text" / "Ex 10").write_text(
"Question 1 text", encoding="utf-8"
)
image = evaluation / "Cutleft" / "Copie42_03.jpg"
image.parent.mkdir()
image.write_bytes(b"image")
repeated = Mock(
text=(
'{"name":"Continued","list":'
'[{"box_2d":[1,2,3,4],"label":"Ex 10 : 1)a)"}]}'
)
)
client = Mock()
client.models.generate_content.side_effect = [
repeated,
repeated,
repeated,
]
output = io.StringIO()
with redirect_stdout(output):
generated = module.process_copy_group(
EvaluationWorkspace(evaluation),
"Copie42",
[image],
client=client,
labels_text="Ex 10 : 1)\nEx 10 : 2)\nEx 10 : 3)\n",
names_text="Student\n",
valid_labels={"Ex 10 : 1)", "Ex 10 : 2)", "Ex 10 : 3)"},
valid_names={"Student", "Unknown", "Continued"},
overwrite=True,
sleep=lambda _seconds: None,
target_interval=0,
)
self.assertEqual(generated, 1)
self.assertEqual(client.models.generate_content.call_count, 3)
seeds = [
call.kwargs["config"].seed
for call in client.models.generate_content.call_args_list
]
self.assertEqual(seeds, [0, 0, 1])
result = read_json(evaluation / "Copies" / "Copie42_03.json")
self.assertEqual(result["list"][0]["label"], "??Ex 10 : 1)a)")
self.assertIn("keeping them with a ?? prefix", output.getvalue())
def test_correction_overwrite_keeps_previous_state_until_a_commit(self) -> None:
module = self.modules["correction"]
with tempfile.TemporaryDirectory() as directory:
+197
View File
@@ -0,0 +1,197 @@
import os
import tempfile
import threading
import unittest
from pathlib import Path
from queue import Queue
from unittest.mock import Mock, patch
from PIL import Image
from copienator import CliError, EvaluationWorkspace, ExitCode
from copienator.copy_errors import copy_errors, clear_copy_error, mark_copy_error, marked_copy_paths
from copienator.commands import cutleft, page_splitter
from copienator_gui.app import CopienatorApp
class MarkedCopiesTests(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.original_copies_dir.mkdir()
self.files = [self.workspace.copies_dir / name for name in ("Copie01.pdf", "Copie02.pdf")]
for path in self.files:
path.write_bytes(b"processed")
(self.workspace.original_copies_dir / path.name).write_bytes(b"original")
def reviewer(self):
review = cutleft.ImageReviewer.__new__(cutleft.ImageReviewer)
review.workspace = self.workspace
review.files = self.files
review.output_dir = self.workspace.cutleft_dir
review.index = 0
review.is_processing = False
review.had_errors = False
review.completed = False
review.current_shift = 50
review.default_max_per_file = 5
review.current_max_per_file = 1
review.root = Mock()
review.stop_prefetch = threading.Event()
review.load_current_image = Mock()
review.update_display = Mock()
image = Image.new("RGB", (8, 8), "white")
review.current_result = (image, [image], {"total_pages": 1, "columns_per_file": [1]})
return review
def test_marks_survive_reload_and_resolve_originals_only_for_splitting(self):
mark_copy_error(self.workspace, self.files[1], "Wrong page order")
reloaded = EvaluationWorkspace(self.workspace.root)
self.assertEqual(marked_copy_paths(reloaded), [self.files[1]])
original = self.workspace.original_copies_dir / self.files[1].name
self.assertEqual(marked_copy_paths(reloaded, originals=True), [original])
self.files[1].unlink()
with self.assertRaises(CliError):
marked_copy_paths(reloaded)
self.assertEqual(marked_copy_paths(reloaded, originals=True), [original])
clear_copy_error(reloaded, original)
self.assertEqual(copy_errors(self.workspace), {})
def test_skip_flags_and_advances_without_replacing_existing_crop(self):
review = self.reviewer()
review.output_dir.mkdir()
previous = review.output_dir / "Copie01_01.jpg"
previous.write_bytes(b"previous crop")
review.handle_processing_result(review.current_result, self.files[0])
review.on_skip()
self.assertEqual(previous.read_bytes(), b"previous crop")
self.assertIn("Copie01.pdf", copy_errors(self.workspace))
self.assertEqual(review.index, 1)
self.assertEqual(review.current_max_per_file, 5)
self.assertTrue(review.had_errors)
review.load_current_image.assert_called_once_with()
def test_accept_saves_and_clears_only_current_flag(self):
for path in self.files:
mark_copy_error(self.workspace, path, "Review needed")
review = self.reviewer()
review.on_next(None)
self.assertTrue((review.output_dir / "Copie01_01.jpg").is_file())
self.assertEqual(set(copy_errors(self.workspace)), {"Copie02.pdf"})
self.assertEqual(review.index, 1)
def test_failed_save_and_window_close_preserve_flag(self):
mark_copy_error(self.workspace, self.files[0], "Review needed")
review = self.reviewer()
with patch.object(cutleft, "save_results", side_effect=OSError("disk full")), patch.object(
cutleft.messagebox, "showerror"
):
review.on_next(None)
self.assertEqual(review.index, 0)
self.assertIn("Copie01.pdf", copy_errors(self.workspace))
review.on_close()
self.assertFalse(review.completed)
self.assertTrue(review.stop_prefetch.is_set())
self.assertIn("Copie01.pdf", copy_errors(self.workspace))
def test_processing_blocks_skip_and_failed_conversion_is_flagged(self):
review = self.reviewer()
review.is_processing = True
review.on_skip()
self.assertEqual(review.index, 0)
self.assertEqual(copy_errors(self.workspace), {})
review.manual_queue = Queue()
review.manual_queue.put(None)
review.load_current_image.side_effect = lambda: setattr(review, "is_processing", True)
review.check_manual_queue(self.files[0])
self.assertIn("Copie01.pdf", copy_errors(self.workspace))
self.assertEqual(review.index, 1)
self.assertTrue(review.is_processing) # Still loading the next copy.
def test_cli_splitting_preserves_flags_and_cropping_reports_partial_or_interrupted(self):
mark_copy_error(self.workspace, self.files[1], "Wrong order")
with patch.object(page_splitter.tk, "Tk"), patch.object(page_splitter, "PDFPreviewer") as preview:
preview.return_value.failed = False
self.assertEqual(page_splitter.main([str(self.workspace.root), "--marked"]), 0)
self.assertEqual(preview.call_args.args[2], [self.workspace.original_copies_dir / "Copie02.pdf"])
self.assertIn("Copie02.pdf", copy_errors(self.workspace))
with patch.object(cutleft, "ImageReviewer") as reviewer:
for completed, errors, expected in ((True, True, ExitCode.PARTIAL),
(False, False, ExitCode.INTERRUPTED),
(True, False, ExitCode.SUCCESS)):
reviewer.return_value.completed = completed
reviewer.return_value.had_errors = errors
self.assertEqual(cutleft.main([str(self.workspace.root), "--marked"]), expected)
self.assertEqual(reviewer.call_args.args[0], [self.files[1]])
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
class MarkedCopiesGuiTests(unittest.TestCase):
setUp = MarkedCopiesTests.setUp
def test_keyboard_skip_then_accept_and_retry_clears_flag(self):
real_tk = cutleft.tk.Tk
def review_with_keys(files, keys):
sent = []
def make_root():
root = real_tk()
def send_when_ready():
info = root.winfo_children()[-1].cget("text")
index = len(sent)
if index < len(keys) and info.startswith(f"[{index + 1}/{len(files)}]"):
root.focus_force()
sent.append(keys[index])
root.event_generate(keys[index])
if len(sent) < len(keys):
root.after(10, send_when_ready)
root.after(20, send_when_ready)
root.after(3000, root.destroy) # Bound a failed keyboard test.
return root
with patch.object(cutleft.tk, "Tk", side_effect=make_root), patch.object(
cutleft, "get_pdf_pages", return_value=[Image.new("RGB", (600, 300), "white")]
), patch.object(cutleft, "OUTPUT_SIZE", (400, 200)):
reviewer = cutleft.ImageReviewer(files, self.workspace.cutleft_dir)
self.assertEqual(sent, keys)
self.assertTrue(reviewer.completed)
return reviewer
first = review_with_keys(self.files, ["<KeyPress-s>", "<Return>"])
self.assertTrue(first.had_errors)
self.assertEqual(set(copy_errors(self.workspace)), {"Copie01.pdf"})
self.assertFalse((self.workspace.cutleft_dir / "Copie01_01.jpg").exists())
self.assertTrue((self.workspace.cutleft_dir / "Copie02_01.jpg").exists())
second = review_with_keys(marked_copy_paths(self.workspace), ["<Return>"])
self.assertFalse(second.had_errors)
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):
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")):
app.tree.selection_set(ident)
app.update()
self.assertIn("(1)", app.marked_copies_button.cget("text"))
with patch.object(app, "_run_current_step") as run:
app.marked_copies_button.invoke()
run.assert_called_once_with()
self.assertIn(command, app._make_command())
self.assertIn("--marked", app._make_command())
self.assertEqual(app.arg_vars["target"].get(), app._evaluation_arg())
app.copy_var.set("Copie01.pdf")
app._target_selected_copy()
self.assertNotIn("--marked", app._make_command())
self.assertIn(str(self.files[0]), app._make_command())
finally:
for callback in app.tk.splitlist(app.tk.call("after", "info")):
app.after_cancel(callback)
app.destroy()
+1
View File
@@ -118,6 +118,7 @@ class SelectiveGeminiTests(unittest.TestCase):
self.assertIn("Barème Gemini", (self.root / "Persp" / label).read_text())
for call in self.client.models.generate_content.call_args_list:
self.assertEqual(call.kwargs["contents"][0].parts[0].text, gemini.PROMPT_4)
self.assertTrue(call.kwargs["config"].automatic_function_calling.disable)
def test_incomplete_or_failed_rubrics_preserve_entire_persp(self):
before = self.snapshot()