Files
Copies/tests/test_gui_convenience.py
T
2026-10-23 08:24:10 +02:00

813 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import tempfile
import unittest
from itertools import product
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
from copienator.commands.page_splitter import _selected_inputs
from copienator import EvaluationWorkspace
from copienator.utils import LATEX_WARNING_MARKER
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
class GuiConvenienceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.repository = Path(self.temp.name)
self.evaluation = self.repository / "Évaluation avec espaces"
self.evaluation.mkdir()
self.app = CopienatorApp(self.repository, False, self.evaluation)
self.app.update()
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_orientation_report_buttons_flag_copies_and_launch_repair(self):
from copienator import atomic_write_json
from copienator.annotation_layout import fingerprint
from copienator.copy_errors import copy_errors
from copienator.orientation import report_path
workspace = EvaluationWorkspace(self.evaluation)
workspace.copies_dir.mkdir(exist_ok=True)
pdf = workspace.copies_dir / 'Copie01.pdf'
pdf.write_bytes(b'copy placeholder for UI')
atomic_write_json(report_path(workspace, 'Copie01'), {
'sha256': fingerprint(pdf), 'images': {'Copie01_01.jpg': [1, 2]},
})
self.app.current_step = self.app.step_by_id['labels']
self.app._render_step()
def buttons(widget):
result = []
for child in widget.winfo_children():
if isinstance(child, ttk.Button):
result.append(child)
result.extend(buttons(child))
return result
next(b for b in buttons(self.app.form) if b.cget('text') == 'Signaler ces copies').invoke()
self.assertIn('Copie01.pdf', copy_errors(workspace))
automatic = next(b for b in buttons(self.app.form)
if b.cget('text') == 'Corriger automatiquement l’orientation')
with patch.object(self.app, '_validate_arguments', return_value=True), \
patch.object(self.app, '_missing_requirements', return_value=[]), \
patch.object(self.app.runner, 'start') as start:
automatic.invoke()
command = start.call_args.args[0]
self.assertEqual(command[-2:], ['repair-orientation', str(self.evaluation)])
self.assertEqual(self.app.active_step_id, 'labels')
def test_personal_review_commands_require_explicit_completion(self):
from copienator_gui.workflow import build_workflow
step = next(step for step in build_workflow(True) if step.id == "personal_read")
self.app.step_by_id[step.id] = step
self.app.current_step = step
self.app._render_step()
self.assertEqual([button.cget("text") for button in self.app.review_buttons],
["gestion_classe re", "gestion_classe wsn", "gestion_classe we"])
self.assertEqual(self.app.command_box.winfo_manager(), "")
self.assertEqual(self.app.run_button.cget("text"), "Marquer réussie")
with patch.object(self.app.runner, "start") as start, \
patch.object(self.app, "_move_selection_from") as advance:
for button, command in zip(self.app.review_buttons, ("re", "wsn", "we")):
button.invoke()
self.assertEqual(start.call_args.args[0], ["gestion_classe", command])
self.app._finish_process(0, False)
self.assertNotEqual(self.app.state_store.step(step.id)["status"], "success")
advance.assert_not_called()
start.reset_mock()
self.app.run_button.invoke()
start.assert_not_called()
self.assertEqual(self.app.state_store.step(step.id)["status"], "success")
def test_annotation_import_cleanup_can_be_unchecked(self):
self.app.tree.selection_set("annotation")
self.app.update()
self.assertTrue(self.app.arg_vars["clear_import"].get())
self.assertIn("--clear-import", self.app._make_command())
self.app.arg_vars["clear_import"].set(False)
self.assertNotIn("--clear-import", self.app._make_command())
self.app.tree.selection_set("export")
self.app.update()
self.app.tree.selection_set("annotation")
self.app.update()
self.assertFalse(self.app.arg_vars["clear_import"].get())
def test_cutleft_visual_check_option_is_below_and_off_by_default(self):
step = self.app.step_by_id["cutleft"]
self.app.current_step = step
self.app._render_step()
labels = {
child.cget("text"): child
for child in self.app.form.winfo_children()
if isinstance(child, ttk.Label)
}
fullpage = labels["Toujours utiliser la page entière ⓘ"]
no_visual_check = labels["et ne pas vérifier visuellement ⓘ"]
self.assertGreater(
int(no_visual_check.grid_info()["row"]),
int(fullpage.grid_info()["row"]),
)
self.assertFalse(self.app.arg_vars["no_visual_check"].get())
@staticmethod
def _label_texts(widget):
return [
text
for child in widget.winfo_children()
for text in (
[str(child.cget("text"))]
if isinstance(child, ttk.Label)
else GuiConvenienceTests._label_texts(child)
)
]
def test_reload_detects_added_and_removed_files_without_advancing(self):
for name in ("enonce.pdf", "enonce.tex", "correction.tex"):
(self.evaluation / name).touch()
self.app._reload_inputs()
self.assertIn("names", self.app.info_var.get())
(self.repository / "names").touch()
self.app._reload_inputs()
self.app.update()
self.assertEqual(self.app.state_store.step("inputs")["status"], "success")
self.assertEqual(self.app.current_step.id, "inputs")
(self.evaluation / "enonce.pdf").unlink()
self.app._reload_inputs()
self.assertEqual(self.app.state_store.step("inputs")["status"], "ready")
self.assertIn("enonce.pdf", self.app.missing_requirements_label.cget("text"))
style = ttk.Style(self.app)
self.assertEqual(
style.lookup("MissingPrerequisite.TLabel", "foreground"), "#c62828"
)
def test_only_successful_gemini_analysis_skips_rubric_replacement(self):
for variant, code, interrupted, expected in (
("gemini", 0, False, "skipped"),
("personal", 0, False, "ready"),
("gemini", 4, False, "ready"),
("gemini", 1, False, "ready"),
("gemini", 0, True, "ready"),
):
with self.subTest(variant=variant, code=code, interrupted=interrupted):
self.app.state_store.update_step("statement_persp", status="ready")
self.app.state_store.update_step("statement_groups", status="ready")
self.app.state_store.update_step("statement", last_run_variant=variant)
self.app.active_step_id = "statement"
with patch.object(self.app, "after_idle"):
self.app._finish_process(code, interrupted)
self.assertEqual(self.app.state_store.step("statement_persp")["status"], expected)
self.assertEqual(self.app.state_store.step("statement_groups")["status"], "ready")
def test_statement_latex_warnings_are_tracked_for_both_variants(self):
for variant in ("gemini", "personal"):
with self.subTest(variant=variant):
self.app._reset_latex_warnings()
self.app.state_store.update_step("statement", last_run_variant=variant)
self.app.active_step_id = "statement"
first = self.evaluation / "Text2" / "Ex 1.pdf"
second = self.evaluation / "Sol2" / "Ex 2.pdf"
marker = LATEX_WARNING_MARKER
self.app._track_latex_warnings(f"noise\n{marker}{first}")
self.assertEqual(self.app._latex_warning_paths, [])
self.app._track_latex_warnings(f"\n{marker}{second}\n{marker}{first}\n")
self.assertEqual(self.app._latex_warning_paths, [first, second])
self.assertTrue(self.app.latex_warning_button.winfo_manager())
with patch.object(self.app, "after_idle") as after_idle:
self.app._finish_process(4, False)
self.assertTrue(
any(call.args[0] == self.app._show_current_latex_warnings
for call in after_idle.call_args_list)
)
self.assertIn("2 problème(s) LaTeX", self.app.info_var.get())
def test_evaluation_buttons_are_ordered_and_open_folder_and_statement(self):
self.assertEqual(int(self.app.evaluation_entry.cget("width")), 42)
self.assertEqual(
int(self.app.browse_evaluation_button.grid_info()["column"]),
int(self.app.evaluation_entry.grid_info()["column"]) + 1,
)
self.assertEqual(
int(self.app.open_evaluation_button.grid_info()["column"]),
int(self.app.browse_evaluation_button.grid_info()["column"]) + 1,
)
self.assertEqual(
int(self.app.open_statement_button.grid_info()["column"]),
int(self.app.open_evaluation_button.grid_info()["column"]) + 1,
)
statement = self.evaluation / "enonce.pdf"
statement.touch()
with patch("copienator_gui.app.open_path") as opened:
self.app.open_evaluation_button.invoke()
self.app.open_statement_button.invoke()
self.assertEqual(
opened.call_args_list,
[unittest.mock.call(self.evaluation), unittest.mock.call(statement)],
)
def test_manual_resolution_panel_follows_command_target(self):
manual = self.evaluation / "manual_resolutions.txt"
manual.write_text("Copie01 A -> B|\n", encoding="utf-8")
self.app.tree.selection_set("manual_resolution")
self.app.update()
self.assertEqual(len(self.app.manual_panel.pdf_buttons), 2)
other = self.repository / "Other"
other.mkdir()
other_manual = other / "manual_resolutions.txt"
other_manual.write_text("Copie02 C xs D\n", encoding="utf-8")
self.app.arg_vars["target"].set(str(other))
self.app.update()
self.assertIn("Copie02", self.app.manual_panel.text.get("1.0", "end"))
with patch("copienator_gui.manual_resolution.open_path") as opened:
self.app.manual_panel.editor_button.invoke()
opened.assert_called_once_with(other_manual)
def test_successful_manual_resolution_returns_to_refaire_correction(self):
manual = self.evaluation / "manual_resolutions.txt"
manual.write_text("Copie01 A -> B|\n", encoding="utf-8")
self.app.state_store.update_step("manual_resolution", visited=True)
self.app.tree.selection_set("manual_resolution")
self.app.update()
self.app.active_step_id = "manual_resolution"
self.app._finish_process(0, False)
self.app.update()
self.assertEqual(self.app.current_step.id, "correction")
self.assertEqual(self.app.variant_var.get(), "refaire")
self.assertEqual(
self.app.state_store.step("correction")["variant"], "refaire"
)
self.assertNotIn("overwrite", self.app.arg_vars)
self.assertIn("--refaire", self.app._make_command())
def test_optional_recorrection_opens_examples_and_returns_to_correction(self):
from copienator import read_json
ids = [step.id for step in self.app.steps]
self.assertEqual(ids[ids.index("manual_resolution") + 1], "prepare_recorrection")
copies = self.evaluation / "Copies"
copies.mkdir()
(copies / "Copie46.pdf").touch()
(self.evaluation / "labels").write_text("II)9)\n")
self.app.tree.selection_set("prepare_recorrection")
self.app.update()
with patch("copienator_gui.app.open_path") as opened:
self.app.edit_recorrection_button.invoke()
path = self.evaluation / "refaire.json"
opened.assert_called_once_with(path)
self.assertEqual(read_json(path), [])
path.write_text(path.read_text().replace("\n[]\n", '\n[["Copie46", []]]\n'))
with patch.object(self.app.runner, "start") as start:
self.app.run_button.invoke()
self.app.update()
start.assert_not_called()
self.assertEqual(self.app.state_store.step("prepare_recorrection")["status"], "success")
self.assertEqual(self.app.current_step.id, "correction")
self.assertEqual(self.app.variant_var.get(), "refaire")
self.assertIn("--refaire", self.app._make_command())
self.assertNotIn("--overwrite", self.app._make_command())
def test_ignoring_optional_recorrection_continues_without_creating_file(self):
self.app.tree.selection_set("prepare_recorrection")
self.app.update()
self.app._skip_step()
self.app.update()
self.assertEqual(self.app.current_step.id, "post_correction")
self.assertEqual(self.app.state_store.step("prepare_recorrection")["status"], "skipped")
self.assertFalse((self.evaluation / "refaire.json").exists())
def test_optional_recorrection_requires_a_saved_selection(self):
self.app.tree.selection_set("prepare_recorrection")
self.app.update()
with patch("copienator_gui.app.messagebox.showerror") as error:
self.app.run_button.invoke()
error.assert_called_once()
self.assertEqual(self.app.current_step.id, "prepare_recorrection")
def test_giving_names_waits_for_explicit_manual_completion(self):
return_dir = self.evaluation / "A Rendre" / "Student (01)"
return_dir.mkdir(parents=True)
(return_dir / "Student.jpg").write_bytes(b"jpg")
(return_dir / "Student.pdf").write_bytes(b"pdf")
self.app.tree.selection_set("giving_names")
self.app.update()
self.app.active_step_id = "giving_names"
self.app._finish_process(0, False)
self.app.update()
self.assertEqual(self.app.current_step.id, "giving_names")
self.assertEqual(
self.app.state_store.step("giving_names")["status"], "detected"
)
self.assertEqual(
self.app.complete_giving_names_button.cget("text"),
"Marquer terminée",
)
self.app.complete_giving_names_button.invoke()
self.app.update()
self.assertEqual(
self.app.state_store.step("giving_names")["status"], "success"
)
self.assertNotEqual(self.app.current_step.id, "giving_names")
def test_grouped_annotation_can_open_label_groups(self):
label_groups = self.evaluation / "label_groups"
label_groups.write_text("Ex 1 : 1), Ex 1 : 2)\n", encoding="utf-8")
self.app.state_store.update_step("annotation", variant="grouped")
self.app.tree.selection_set("annotation")
self.app.update()
self.assertIsNotNone(self.app.label_groups_button)
self.assertEqual(
self.app.label_groups_button.cget("text"),
"Ouvrir les groupes de questions",
)
with patch("copienator_gui.app.open_path") as opened:
self.app.label_groups_button.invoke()
opened.assert_called_once_with(label_groups)
self.app._select_variant(0)
self.assertIsNone(self.app.label_groups_button)
def test_batch_status_waits_until_all_jobs_are_ready(self):
self.app.state_store.update_step("correction", variant="batch", last_run_variant="batch")
self.app._populate_tree()
self.app.state_store.update_step("batch_status", visited=True)
self.app.tree.selection_set("batch_status")
self.app.update()
command = self.app._make_command()
self.assertIn("--evaluation", command)
self.assertFalse(self.app.arg_vars)
self.app.active_step_id = "batch_status"
self.app._finish_process(4, False)
self.app.update()
self.assertEqual(self.app.current_step.id, "batch_status")
self.app.active_step_id = "batch_status"
self.app._finish_process(0, False)
self.app.update()
self.assertEqual(self.app.current_step.id, "fetch_batches")
def test_batch_retrieval_success_selects_integration_without_starting_it(self):
self.app.state_store.update_step("correction", variant="batch", last_run_variant="batch")
self.app._populate_tree()
self.app.tree.selection_set("fetch_batches")
self.app.update()
self.app.active_step_id = "fetch_batches"
with patch.object(self.app.runner, "start") as start:
self.app._finish_process(0, False)
self.app.update()
start.assert_not_called()
self.assertEqual(self.app.current_step.id, "correction")
self.assertEqual(self.app.variant_var.get(), "integrate")
self.assertEqual(self.app.state_store.step("correction")["variant"], "integrate")
self.assertIn("--deal-with-batched", self.app._make_command())
def test_batch_integration_success_selects_conflicts_or_cleanup(self):
for conflicts, visited in product((None, "### Aucun conflit\n", "Copie01 A -> B\n"), (False, True)):
with self.subTest(conflicts=conflicts, visited=visited):
manual = self.evaluation / "manual_resolutions.txt"
if conflicts is None:
manual.unlink(missing_ok=True)
else:
manual.write_text(conflicts, encoding="utf-8")
(self.evaluation / "correction.json").write_text("{}", encoding="utf-8")
self.app.state_store.update_step(
"correction", variant="integrate", last_run_variant="integrate"
)
self.app.state_store.update_step("manual_resolution", visited=visited, status="stale")
self.app._populate_tree()
self.app.tree.selection_set("correction")
self.app.update()
self.app.active_step_id = "correction"
with patch.object(self.app.runner, "start") as start:
self.app._finish_process(0, False)
self.app.update()
start.assert_not_called()
expected = "manual_resolution" if conflicts == "Copie01 A -> B\n" else "post_correction"
self.assertEqual(self.app.current_step.id, expected)
expected_status = "stale" if expected == "manual_resolution" else "success"
self.assertEqual(self.app.state_store.step("manual_resolution")["status"], expected_status)
if expected_status == "success":
self.assertEqual(self.app.tree.item("manual_resolution", "values"), ("Réussie",))
for step_id in ("submit_batches", "batch_status", "fetch_batches", "prepare_recorrection"):
self.assertFalse(self.app.state_store.step(step_id).get("visited"))
def test_unsuccessful_batch_integration_stays_on_correction(self):
self.app.state_store.update_step(
"correction", variant="integrate", last_run_variant="integrate"
)
self.app._populate_tree()
self.app.tree.selection_set("correction")
self.app.update()
for code, interrupted in ((4, False), (1, False), (0, True)):
with self.subTest(code=code, interrupted=interrupted):
self.app.active_step_id = "correction"
self.app._finish_process(code, interrupted)
self.app.update()
self.assertEqual(self.app.current_step.id, "correction")
def test_correction_invalidates_batches_only_when_regenerating_or_resetting(self):
for variant, action in product(
("integrate", "live", "refaire", "batch", "hybrid", "reset"), ("run", "mark")
):
with self.subTest(variant=variant, action=action):
self.app.active_step_id = None
self.app.current_step = None
self.app.state_store.update_step("correction", variant=variant, batch_started=True)
statuses = {"submit_batches": "success", "batch_status": "detected",
"fetch_batches": "success", "annotation": "success"}
for step_id, status in statuses.items():
self.app.state_store.update_step(step_id, status=status)
self.app._populate_tree()
self.app.tree.selection_set("correction")
self.app.update()
with patch.object(self.app.runner, "start"), \
patch.object(self.app, "_missing_requirements", return_value=[]), \
patch("copienator_gui.app.messagebox.askyesno", return_value=True):
if action == "run":
self.app._run_current_step()
self.assertEqual(self.app.active_step_id, "correction")
else:
self.app._mark_step("success")
for step_id, status in statuses.items():
preserve = variant in {"integrate", "live", "refaire"} and step_id != "annotation"
expected = status if preserve else "stale"
self.assertEqual(self.app.state_store.step(step_id)["status"], expected)
self.app.active_step_id = None
def test_checking_batch_status_preserves_downstream_statuses(self):
self.app.state_store.update_step("correction", batch_started=True)
statuses = {"fetch_batches": "success", "annotation": "detected"}
for step_id, status in statuses.items():
self.app.state_store.update_step(step_id, status=status)
self.app._populate_tree()
self.app.tree.selection_set("batch_status")
self.app.update()
with patch.object(self.app.runner, "start"), \
patch.object(self.app, "_missing_requirements", return_value=[]):
self.app._run_current_step()
self.assertEqual(self.app.active_step_id, "batch_status")
self.app.active_step_id = None
self.app._mark_step("success")
for step_id, status in statuses.items():
self.assertEqual(self.app.state_store.step(step_id)["status"], status)
def test_unsuccessful_batch_retrieval_stays_on_retrieval(self):
self.app.state_store.update_step("correction", variant="batch", last_run_variant="batch")
self.app._populate_tree()
self.app.tree.selection_set("fetch_batches")
self.app.update()
for code, interrupted in ((4, False), (1, False), (0, True)):
with self.subTest(code=code, interrupted=interrupted):
self.app.active_step_id = "fetch_batches"
self.app._finish_process(code, interrupted)
self.app.update()
self.assertEqual(self.app.current_step.id, "fetch_batches")
self.assertEqual(self.app.state_store.step("correction")["variant"], "batch")
def test_batch_watch_buttons_use_loaded_evaluation_and_stop_on_reload(self):
self.app.state_store.update_step("correction", variant="batch", last_run_variant="batch")
self.app._populate_tree()
self.app.tree.selection_set("batch_status")
self.app.update()
with patch.object(self.app.batch_monitor, "start") as start:
self.app.watch_batches_button.invoke()
self.assertEqual(start.call_args.args[0][-2:], ["--evaluation", str(self.evaluation)])
self.app.batch_monitor.active = True
self.app._update_controls()
self.assertEqual(str(self.app.stop_watch_batches_button.cget("state")), "normal")
self.app.stop_watch_batches_button.invoke()
self.assertFalse(self.app.batch_monitor.active)
self.app.batch_monitor.active = True
self.app._load_evaluation()
self.assertFalse(self.app.batch_monitor.active)
def test_background_batch_readiness_notifies_without_changing_other_step(self):
self.app.tree.selection_set("inputs")
self.app.update()
with patch("copienator_gui.app.notify_desktop") as notify:
self.app._batch_results_ready()
notify.assert_called_once()
self.assertIn(self.evaluation.name, notify.call_args.args[1])
self.assertEqual(self.app.current_step.id, "inputs")
self.assertEqual(self.app.state_store.step("batch_status")["status"], "success")
def test_batch_steps_hidden_until_a_correction_is_started(self):
batch_ids = ("submit_batches", "batch_status", "fetch_batches")
self.app.state_store.update_step("correction", variant="batch")
self.app._populate_tree()
for step_id in batch_ids:
self.assertFalse(self.app.tree.exists(step_id))
self.assertNotIn(step_id, self.app._progression_ids("correction"))
self.app.state_store.update_step("correction", last_run_variant="hybrid", status="running")
self.app._populate_tree()
for step_id in batch_ids:
self.assertTrue(self.app.tree.exists(step_id))
self.assertIn(step_id, self.app._progression_ids("correction"))
def test_existing_batch_artifacts_reveal_steps_even_in_live_mode(self):
for filename in ("batch_requests_flash.jsonl", "batch_requests_pro.jsonl",
"batch_jobs.json", "batched_correction_result.jsonl"):
with self.subTest(filename=filename):
path = self.evaluation / filename
path.write_text("")
self.app._populate_tree()
self.assertFalse(self.app.tree.exists("batch_status"))
path.write_text('{"jobs":{"flash":{"name":"batches/1"}}}\n')
self.app._populate_tree()
self.assertTrue(self.app.tree.exists("batch_status"))
self.app._handle_first_visit(self.app.step_by_id["batch_status"])
self.app.update()
self.assertNotEqual(self.app.state_store.step("batch_status").get("status"), "skipped")
path.unlink()
def test_started_batches_remain_visible_after_mode_change_and_reload(self):
self.app.state_store.update_step("correction", batch_started=True, last_run_variant="live")
self.app.state_store.load(self.evaluation)
self.app._populate_tree()
self.assertTrue(self.app.tree.exists("batch_status"))
fresh = self.repository / "Fresh"
fresh.mkdir()
self.app.state_store.load(fresh)
self.app._populate_tree()
self.assertFalse(self.app.tree.exists("batch_status"))
def test_navigation_skips_hidden_batch_steps(self):
with patch.object(self.app.tree, "selection_set") as select:
self.app._move_selection_from("correction", 1)
select.assert_called_once_with("manual_resolution")
def test_long_running_steps_notify_once_on_success(self):
expected_text = {
"labels": "labels",
"correction": "correction",
"annotation": "copies annotées",
"read_annotations": "annotations manuscrites",
}
for step_id, completion_text in expected_text.items():
with self.subTest(step=step_id), patch("copienator_gui.app.notify_desktop") as notify:
self.app.active_step_id = step_id
self.app._finish_process(0, False)
self.app._finish_process(0, False)
notify.assert_called_once()
self.assertIn(self.evaluation.name, notify.call_args.args[1])
self.assertIn(completion_text, notify.call_args.args[1])
def test_incomplete_failed_and_interrupted_steps_notify_the_outcome_once(self):
for step_id in ("labels", "correction", "annotation", "read_annotations"):
for code, interrupted, outcome in (
(4, False, "partielle"), (1, False, "échouée"),
(0, True, "interrompue"), (130, False, "interrompue"),
):
with self.subTest(step=step_id, code=code, interrupted=interrupted), \
patch("copienator_gui.app.notify_desktop") as notify:
self.app.active_step_id = step_id
self.app._finish_process(code, interrupted)
self.app._finish_process(code, interrupted)
notify.assert_called_once()
title, message = notify.call_args.args
self.assertIn(outcome, title)
self.assertIn(outcome, message)
self.assertIn(self.evaluation.name, message)
self.assertIn(f"code {code}", message)
self.assertNotIn("réussie", message)
def test_notification_failure_keeps_success_and_uses_existing_fallback(self):
self.app.active_step_id = "labels"
with patch("copienator_gui.app.notify_desktop", side_effect=RuntimeError("unavailable")), \
patch.object(self.app, "bell") as bell, \
patch.object(self.app, "_append_console") as console:
self.app._finish_process(0, False)
bell.assert_called_once()
self.assertTrue(any("Notification de bureau indisponible" in call.args[0]
for call in console.call_args_list))
self.assertEqual(self.app.state_store.step("labels")["status"], "success")
def test_optional_blank_crop_can_target_a_copy_or_be_skipped(self):
copies = self.evaluation/"Copies"
copies.mkdir()
source = copies/"Copie01.pdf"
source.touch()
self.app.state_store.update_step("crop_blank_margins", visited=True)
self.app.tree.selection_set("crop_blank_margins")
self.app.update()
self.assertEqual(self.app.current_step.id, "crop_blank_margins")
self.assertEqual(str(self.app.skip_button.cget("state")), "normal")
self.app.copy_var.set(source.name)
self.app._target_selected_copy()
command = self.app._make_command()
self.assertEqual(command[-4:], ["crop-margins", str(source), "--workers", "5"])
self.app._skip_step()
self.app.update()
self.assertEqual(self.app.state_store.step("crop_blank_margins")["status"], "skipped")
self.assertNotEqual(self.app.current_step.id, "crop_blank_margins")
def test_optional_answer_bottom_crop_uses_parallel_workers_and_can_be_skipped(self):
answers = self.evaluation / "Copies" / "Copie01"
answers.mkdir(parents=True)
(answers / "Ex 1.pdf").touch()
self.app.state_store.update_step("crop_exercise_bottoms", visited=True)
self.app.tree.selection_set("crop_exercise_bottoms")
self.app.update()
self.assertEqual(self.app.current_step.id, "crop_exercise_bottoms")
self.assertEqual(str(self.app.skip_button.cget("state")), "normal")
command = self.app._make_command()
self.assertEqual(
command[-4:],
["crop-answer-bottoms", self.app._evaluation_arg(), "--workers", "5"],
)
self.app._skip_step()
self.app.update()
self.assertEqual(
self.app.state_store.step("crop_exercise_bottoms")["status"], "skipped"
)
self.assertNotEqual(self.app.current_step.id, "crop_exercise_bottoms")
def test_redo_targets_selected_copy_and_copies_runnable_command(self):
for folder in ("Copies", "Copies Originales"):
(self.evaluation / folder).mkdir()
for name in ("Copie01.pdf", "Copie02.pdf"):
(self.evaluation / folder / name).touch()
self.app.tree.selection_set("page_splitter")
self.app.update()
self.app.copy_var.set("Copie02.pdf")
self.app._target_selected_copy()
command = self.app._make_command()
target = Path(command[-1])
self.assertEqual(target, self.evaluation / "Copies" / "Copie02.pdf")
self.assertEqual(_selected_inputs(EvaluationWorkspace(self.evaluation), target),
[self.evaluation / "Copies Originales" / "Copie02.pdf"])
self.app._copy_command()
self.assertEqual(self.app.clipboard_get(), command_display(command))
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[5]), 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_answer_splitting_waits_for_target_and_can_select_one_copy(self):
copies = self.evaluation / "Copies"
copies.mkdir()
for name in ("Copie01.pdf", "Copie46.pdf"):
(copies / name).touch()
with patch.object(self.app, "_run_current_step") as run:
self.app.tree.selection_set("splitting")
self.app.update()
run.assert_not_called()
self.assertIn("Copie46.pdf", self.app.copy_paths)
self.app.copy_var.set("Copie46.pdf")
self.app._target_selected_copy()
command = self.app._make_command()
self.assertIn("split-answers", command)
self.assertEqual(Path(command[5]), copies / "Copie46.pdf")
self.app._target_all_pages()
command = self.app._make_command()
self.assertEqual((self.repository / command[5]).resolve(), self.evaluation)
def test_label_review_can_show_and_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("plotting")
self.app.update()
self.app.copy_var.set("Copie02.pdf")
with patch("copienator_gui.app.open_path") as opened:
self.app._open_selected_copy()
opened.assert_called_once_with(copies / "Copie02.pdf")
self.app._target_selected_copy()
command = self.app._make_command()
self.assertEqual(Path(command[-1]), copies / "Copie02.pdf")
labels = self._label_texts(self.app.form)
self.assertTrue(
any("Seule la copie ciblée sera vérifiée" in text for text in labels)
)
def test_free_form_arguments_are_shown_only_when_documented(self):
self.app.tree.selection_set("labels")
self.app.update()
labels_text = self._label_texts(self.app.form)
extra_label = next(
child
for child in self.app.form.winfo_children()
if isinstance(child, ttk.Label)
and str(child.cget("text")).startswith("Arguments supplémentaires")
)
tooltip = extra_label._copienator_tooltip
self.assertIn("Cutleft", tooltip.text)
self.assertFalse(any("Cutleft" in text for text in labels_text))
tooltip.show()
self.app.update()
self.assertIsNotNone(tooltip.window)
self.assertIn("Cutleft", tooltip.window.winfo_children()[0].cget("text"))
tooltip.hide()
self.app.tree.selection_set("plotting")
self.app.update()
self.assertFalse(
any(
text.startswith("Arguments supplémentaires")
for text in self._label_texts(self.app.form)
)
)
def test_each_visible_argument_label_has_a_tooltip(self):
self.app.tree.selection_set("correction")
self.app.update()
argument_labels = [
child
for child in self.app.form.winfo_children()
if isinstance(child, ttk.Label) and str(child.cget("text")).endswith("ⓘ")
]
self.assertGreaterEqual(len(argument_labels), 4)
for label in argument_labels:
with self.subTest(label=label.cget("text")):
tooltip = getattr(label, "_copienator_tooltip", None)
self.assertIsNotNone(tooltip)
self.assertTrue(tooltip.text.strip())
def test_verbose_checkbox_updates_supported_commands(self):
self.app.tree.selection_set("labels")
self.app.update()
self.assertNotIn("--verbose", self.app._make_command())
self.app.verbose_var.set(True)
self.assertIn("--verbose", self.app._make_command())
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")
self.app._append_console("Suite\n")
self.app._copy_console_selection()
self.assertEqual(self.app.clipboard_get(), "Première ligne")
self.app._copy_console_all()
expected = "Première ligne\nDeuxième ligne\nSuite\n"
self.assertEqual(self.app.clipboard_get(), expected)
self.app.console.insert("end", "unwanted edit")
self.assertEqual(self.app.console.get("1.0", "end-1c"), expected)
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