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_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