From c155e3eebce8916f455e209d272b9e1196c28b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Tue, 6 Oct 2026 11:11:03 +0200 Subject: [PATCH] Manual conflict : utilities --- copienator/answer_origins.py | 20 ++++++++++++++ copienator_gui/manual_resolution.py | 40 +++++++++++++++++++++++++-- tests/test_gui_manual_resolution.py | 43 ++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/copienator/answer_origins.py b/copienator/answer_origins.py index 5efb733..b0bfe67 100644 --- a/copienator/answer_origins.py +++ b/copienator/answer_origins.py @@ -11,6 +11,10 @@ def _path(root): def origins_for(root, copy_id, label, pdf): entry = read_json(_path(root), default={}).get(str(copy_id), {}).get(label, {}) + return _valid_origins(entry, pdf) + + +def _valid_origins(entry, pdf): if not entry or not Path(pdf).is_file(): return [] if entry.get("sha256") != sha256(Path(pdf).read_bytes()).hexdigest(): @@ -18,6 +22,22 @@ def origins_for(root, copy_id, label, pdf): return entry.get("labels", []) +def populated_labels(root, copy_id, source_label): + """Find current answer PDFs populated from this source's recorded content.""" + entries = read_json(_path(root), default={}).get(str(copy_id), {}) + copies = Path(root) / "Copies" / f"Copie{copy_id}" + populated = [] + for label, entry in entries.items(): + if label == source_label or source_label not in entry.get("labels", []): + continue + pdf = copies / f"{label}.pdf" + if not pdf.is_file(): + pdf = copies / f"{label}_new.pdf" + if source_label in _valid_origins(entry, pdf): + populated.append(label) + return sorted(populated) + + def save_origins(root, copy_id, label, pdf, origins): entry = {"labels": list(dict.fromkeys(origins)), "sha256": sha256(Path(pdf).read_bytes()).hexdigest()} diff --git a/copienator_gui/manual_resolution.py b/copienator_gui/manual_resolution.py index 60c0fab..6f5c4f8 100644 --- a/copienator_gui/manual_resolution.py +++ b/copienator_gui/manual_resolution.py @@ -5,6 +5,7 @@ from pathlib import Path from tkinter import messagebox, ttk from copienator import CliError +from copienator.answer_origins import populated_labels from copienator.commands.resolve_manual import get_actual_pdf, parse_instruction_text from copienator.platform import open_path from .cut_helper import CutHelper @@ -41,6 +42,7 @@ class ManualResolutionPanel(ttk.Frame): self.get_evaluation = get_evaluation self.pdf_buttons = [] self.cut_buttons = [] + self.copy_buttons = [] actions = ttk.Frame(self) actions.pack(fill="x") self.editor_button = ttk.Button( @@ -92,11 +94,25 @@ class ManualResolutionPanel(ttk.Frame): except (OSError, RuntimeError) as exc: messagebox.showerror("Ouverture du PDF", str(exc)) + def open_copy(self, evaluation, copy_id, *, directory=False): + path = evaluation / "Copies" / f"Copie{copy_id}" + if not directory: + path = path.with_suffix(".pdf") + try: + if directory and not path.is_dir(): + raise FileNotFoundError(f"Dossier introuvable : {path}") + if not directory and not path.is_file(): + raise FileNotFoundError(f"PDF introuvable : {path}") + open_path(path) + except (OSError, RuntimeError) as exc: + messagebox.showerror("Ouverture du dossier" if directory else "Ouverture du PDF", str(exc)) + def reload(self): - for button in self.pdf_buttons + self.cut_buttons: + for button in self.pdf_buttons + self.cut_buttons + self.copy_buttons: button.destroy() self.pdf_buttons.clear() self.cut_buttons.clear() + self.copy_buttons.clear() self.cut_result.set("") self.text.configure(state="normal") self.text.delete("1.0", "end") @@ -111,7 +127,10 @@ class ManualResolutionPanel(ttk.Frame): else: self.editor_button.configure(state="normal") malformed = [] + provenance = {} + provenance_error = "" for number, raw in enumerate(content.splitlines(keepends=True), 1): + populated = [] self.text.insert("end", raw.rstrip("\r\n")) try: instructions = parse_instruction_text(raw) @@ -120,6 +139,21 @@ class ManualResolutionPanel(ttk.Frame): else: if instructions: instruction = instructions[0] + key = (instruction.copy_id, instruction.old_label) + if key not in provenance: + try: + provenance[key] = populated_labels(path.parent, *key) + except (OSError, ValueError) as exc: + provenance[key] = [] + provenance_error = f" — Provenance indisponible : {exc}" + populated = provenance[key] + for title, directory in (("pdf", False), ("dir", True)): + button = ttk.Button( + self.text, text=title, width=3, + command=lambda directory=directory, copy_id=instruction.copy_id, root=path.parent: self.open_copy(root, copy_id, directory=directory), + ) + self.copy_buttons.append(button) + self.text.window_create("end", window=button, padx=2) for title, label in (("Source", instruction.old_label), ("Cible", instruction.new_label)): button = ttk.Button( @@ -132,12 +166,14 @@ class ManualResolutionPanel(ttk.Frame): command=lambda item=instruction, root=path.parent: self.open_cut(root, item)) self.cut_buttons.append(button) self.text.window_create("end", window=button, padx=8) + if populated: + self.text.insert("end", "\n# -> " + ", ".join(populated)) if raw.endswith("\n"): self.text.insert("end", "\n") detail = (" — lignes invalides : " + ", ".join(malformed)) if malformed else ( f" — {len(self.pdf_buttons) // 2} instruction(s)" ) - self.status.configure(text=str(path) + detail) + self.status.configure(text=str(path) + detail + provenance_error) finally: self.text.configure(state="disabled") diff --git a/tests/test_gui_manual_resolution.py b/tests/test_gui_manual_resolution.py index 6561217..e644368 100644 --- a/tests/test_gui_manual_resolution.py +++ b/tests/test_gui_manual_resolution.py @@ -5,6 +5,7 @@ import unittest from pathlib import Path from unittest.mock import call, patch +from copienator.answer_origins import save_origins from copienator_gui.manual_resolution import ManualResolutionPanel @@ -34,17 +35,27 @@ class ManualResolutionPanelTests(unittest.TestCase): path.parent.mkdir(parents=True, exist_ok=True) path.touch() paths.append(path) + copy_paths = [] + for copy in ("01", "02"): + pdf = self.evaluation / "Copies" / f"Copie{copy}.pdf" + pdf.touch() + copy_paths.extend((pdf, pdf.with_suffix(""))) panel = self.panel() self.assertEqual(panel.text.get("1.0", "end-1c"), content) self.assertEqual(len(panel.pdf_buttons), 4) self.assertEqual([button.cget("text") for button in panel.pdf_buttons], ["Source : Ex 1", "Cible : Ex 2", "Source : Ex 3", "Cible : Ex 4"]) + self.assertEqual([button.cget("text") for button in panel.copy_buttons], + ["pdf", "dir", "pdf", "dir"]) with patch("copienator_gui.manual_resolution.open_path") as opened: panel.editor_button.invoke() for button in panel.pdf_buttons: button.invoke() - self.assertEqual(opened.call_args_list, [call(self.path), *map(call, paths)]) + for button in panel.copy_buttons: + button.invoke() + self.assertEqual(opened.call_args_list, + [call(self.path), *map(call, paths), *map(call, copy_paths)]) def test_reload_updates_the_displayed_target_and_its_pdf_action(self): self.path.write_text("Copie01 A -> B|\n", encoding="utf-8") @@ -63,6 +74,34 @@ class ManualResolutionPanelTests(unittest.TestCase): panel.pdf_buttons[1].invoke() opened.assert_called_once_with(copies / "C.pdf") + def test_populated_labels_appear_under_each_relevant_conflict(self): + content = "Copie01 A -> B|\nCopie02 A -> B|\nCopie01 A -> C|" + self.path.write_text(content, encoding="utf-8") + copies = self.evaluation / "Copies" / "Copie01" + copies.mkdir(parents=True) + for label, filename in (("label2", "label2_new.pdf"), + ("label1", "label1.pdf"), + ("stale", "stale.pdf"), + ("archived", "archived_old.pdf")): + pdf = copies / filename + pdf.write_bytes(b"answer") + save_origins(self.evaluation, "01", label, pdf, ["A"]) + (copies / "stale.pdf").write_bytes(b"changed answer") + panel = self.panel() + expected = ( + "Copie01 A -> B|\n# -> label1, label2\n" + "Copie02 A -> B|\n" + "Copie01 A -> C|\n# -> label1, label2" + ) + self.assertEqual(panel.text.get("1.0", "end-1c"), expected) + self.assertEqual(self.path.read_text(encoding="utf-8"), content) + self.assertEqual(len(panel.pdf_buttons), 6) + self.assertIn("3 instruction(s)", panel.status.cget("text")) + (copies / "label1.pdf").unlink() + panel.reload() + self.assertEqual(panel.text.get("1.0", "end-1c"), + expected.replace("label1, label2", "label2")) + def test_reload_keeps_invalid_lines_visible_and_removes_stale_actions(self): self.path.write_text("Copie01 A -> B|\n", encoding="utf-8") panel = self.panel() @@ -71,9 +110,11 @@ class ManualResolutionPanelTests(unittest.TestCase): self.assertIn("bad instruction", panel.text.get("1.0", "end")) self.assertIn("lignes invalides : 1", panel.status.cget("text")) self.assertEqual(len(panel.pdf_buttons), 2) + self.assertEqual(len(panel.copy_buttons), 2) self.path.unlink() panel.reload() self.assertFalse(panel.pdf_buttons) + self.assertFalse(panel.copy_buttons) self.assertEqual(str(panel.editor_button.cget("state")), "disabled") def test_missing_source_does_not_prevent_opening_destination(self):