32k pixel as hard group limit, better manual resolution support

This commit is contained in:
sebastien committed 2026-09-22 11:42:16 +02:00
1 parent a9c0703f03
commit 5009831c7c
9 files changed
+316 -52

No files matched your search

+37 -20
View File
@@ -27,7 +27,7 @@ from copienator.commands import annotating, annotating_with_checks
from copienator.filesystem import staged_directory
from copienator.utils import natural_key
MAX_HEIGHT_PX = 25000
MAX_HEIGHT_PX = 32000
def render_item(item):
@@ -63,6 +63,11 @@ def save_batch(batch, prefix, group_id, output_root: Path) -> None:
print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)")
max_width = max(item[2].width for item in batch)
total_height = sum(item[2].height for item in batch)
if total_height > MAX_HEIGHT_PX:
raise CliError(
f"{prefix} G{group_id} would be {total_height} px high; "
f"the maximum is {MAX_HEIGHT_PX} px."
)
concatenated = Image.new("RGB", (max_width, total_height), "white")
draw = ImageDraw.Draw(concatenated)
checkbox_map: list[dict[str, Any]] = []
@@ -268,32 +273,44 @@ def _existing_group_state(
def split_batches(rendered):
def split(maximum_height: float):
batches = []
current = []
current_height = 0
previous_student = None
for item in rendered:
student_id = item[0]
image_height = item[2].height
if (
current
and current_height + image_height > maximum_height
and student_id != previous_student
):
def flush() -> None:
nonlocal current, current_height
if current:
batches.append(current)
current = []
current_height = 0
current.append(item)
current_height += image_height
previous_student = student_id
if current:
batches.append(current)
return batches
strict = split(MAX_HEIGHT_PX)
relaxed = split(1.1 * MAX_HEIGHT_PX)
return relaxed if len(relaxed) < len(strict) else strict
index = 0
while index < len(rendered):
student_id = rendered[index][0]
end = index + 1
while end < len(rendered) and rendered[end][0] == student_id:
end += 1
student_items = rendered[index:end]
student_height = sum(item[2].height for item in student_items)
if student_height > MAX_HEIGHT_PX:
labels = ", ".join(item[1] for item in student_items)
raise CliError(
f"Copie{student_id} ({labels}) is {student_height} px high; "
f"one student's labels cannot be split across groups and the "
f"maximum group height is {MAX_HEIGHT_PX} px."
)
# Consider the student's complete block before adding its first image.
# Otherwise later images from that same student could overflow the batch.
if current and current_height + student_height > MAX_HEIGHT_PX:
flush()
current.extend(student_items)
current_height += student_height
index = end
flush()
return batches
def _generate_groups(
+3 -1
View File
@@ -1026,7 +1026,9 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
manual_path = INPUT_DIR / "manual_resolutions.txt"
atomic_write_text(
manual_path,
"### Use -> x>, -x, ss, sx, xx, xs, c{43}1>, c{43}2x\n"
"### Use -> x>, -x, ss, sx, xx, xs, c{43}1>, c{43}2x; "
"delete a line to dismiss its conflict and keep its PDFs unchanged, "
"then run resolve-manual (even if all lines were deleted).\n"
+ "\n".join(unresolved_delayed)
+ "\n",
)
+24 -7
View File
@@ -132,6 +132,18 @@ def set_suffix_and_clean_error(
result["error"] = error
def pending_manual_conflicts(results: dict[str, Any]) -> set[tuple[str, str, str]]:
"""Return the copy/source/target conflicts listed by correction's manual file."""
return {
(item["id"], label, target)
for label, batches in results.items()
for batch in batches
for item in batch
for kind, target in item.get("result", {}).get("delayed", [])
if kind in {"wrong-label", "add-label"}
}
def get_actual_pdf(copies_dir: Path, copy_id: str, label: str) -> Path:
base = copies_dir / f"Copie{copy_id}" / f"{label}.pdf"
for candidate in (
@@ -157,13 +169,7 @@ def _validate_pdf_inputs(
missing: set[Path] = set()
for instruction in instructions:
source = initial_paths[(instruction.copy_id, instruction.old_label)]
destination = initial_paths[(instruction.copy_id, instruction.new_label)]
if instruction.should_merge:
if not source.exists():
missing.add(source)
if not destination.exists():
missing.add(destination)
elif instruction.should_copy and not source.exists():
if (instruction.should_merge or instruction.should_copy) and not source.exists():
missing.add(source)
if missing:
rendered = ", ".join(str(path) for path in sorted(missing))
@@ -178,6 +184,10 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
raise CliError("correction.json must contain a JSON object")
results: dict[str, Any] = loaded
instructions = parse_instructions(workspace.manual_resolutions_file)
retained_conflicts = {
(item.copy_id, item.old_label, item.new_label) for item in instructions
}
dismissed_conflicts = pending_manual_conflicts(results) - retained_conflicts
cut_sources = [(item.copy_id, item.old_label) for item in instructions if item.cut]
if len(set(cut_sources)) != len(cut_sources):
raise CliError("Une seule coupe par label source est autorisée dans une résolution.")
@@ -226,8 +236,10 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
try:
if instruction.pipe_first:
writer.append(source)
if destination.exists():
writer.append(destination)
else:
if destination.exists():
writer.append(destination)
writer.append(source)
writer.write(temp_output)
@@ -285,6 +297,9 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
if temporary not in used_temps:
temporary.unlink(missing_ok=True)
for copy_id, label, target in dismissed_conflicts:
# A removed instruction acknowledges the conflict without touching PDFs.
set_suffix_and_clean_error(results, copy_id, label, None, target)
atomic_write_json(workspace.correction_file, results)
refaire_tasks = [[copy_name, labels] for copy_name, labels in refaire_by_copy.items()]
if refaire_tasks:
@@ -292,6 +307,8 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
workspace.manual_resolutions_file.unlink()
print("Manual resolutions successfully applied.")
if dismissed_conflicts:
print(f"{len(dismissed_conflicts)} removed conflict(s) dismissed; their PDFs were kept unchanged.")
if refaire_tasks:
print(
f"File {workspace.refaire_file.name} generated. Run "
+34 -8
View File
@@ -10,7 +10,8 @@ from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import Any
from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json
from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json, read_json
from copienator.commands.resolve_manual import pending_manual_conflicts
from copienator.copy_errors import copy_errors
from copienator.filesystem import staged_files
from copienator.platform import (
@@ -244,7 +245,14 @@ def has_manual_conflicts(path: Path) -> bool:
# If the file cannot be inspected, keep the step visible rather than
# silently claiming that there is nothing to resolve.
return True
return any(line.strip() and not line.lstrip().startswith("###") for line in lines)
if any(line.strip() and not line.lstrip().startswith("###") for line in lines):
return True
# An emptied manual file still needs execution to acknowledge deletions.
try:
results = read_json(path.parent / "correction.json", default={})
return bool(pending_manual_conflicts(results))
except (OSError, ValueError, TypeError, KeyError, AttributeError):
return True
class CopienatorApp(tk.Tk):
@@ -1459,6 +1467,10 @@ class CopienatorApp(tk.Tk):
self._save_current_form()
run_values = self._values()
ordered_ids = self._progression_ids(step.id)
if step.id == "correction" and variant.id == "integrate":
# Integration consumes the retrieved batches; it does not invalidate them.
ordered_ids = [step_id for step_id in ordered_ids
if not self.step_by_id[step_id].skip_for_live_correction]
self.state_store.invalidate_after(ordered_ids, step.id)
self.state_store.update_step(
step.id,
@@ -1728,11 +1740,14 @@ class CopienatorApp(tk.Tk):
"sur « Marquer terminée »."
)
return
if step_id in {"labels", "correction"} and self.state_store.evaluation:
completion = (
"détection des labels terminée"
if step_id == "labels" else "étape de correction terminée"
)
notified_completions = {
"labels": "détection des labels terminée",
"correction": "étape de correction terminée",
"annotation": "génération des copies annotées terminée",
"read_annotations": "lecture des annotations manuscrites terminée",
}
if step_id in notified_completions and self.state_store.evaluation:
completion = notified_completions[step_id]
outcome = STATUS_LABELS[status].lower()
self._notify_completion(
f"Copienator — {completion} ({outcome})",
@@ -1744,7 +1759,9 @@ class CopienatorApp(tk.Tk):
self.batch_monitor.stop()
self._batch_results_ready()
return
if step_id == "manual_resolution":
if step_id == "fetch_batches":
self.after_idle(self._select_batch_integration)
elif step_id == "manual_resolution":
self.after_idle(self._select_refaire_correction)
else:
self.after_idle(
@@ -1842,6 +1859,15 @@ class CopienatorApp(tk.Tk):
self.tree.selection_set(ids[target])
self.tree.see(ids[target])
def _select_batch_integration(self) -> None:
"""Prepare integration after batch results have been retrieved."""
if self.runner.running or self.active_step_id or not self.state_store.evaluation:
return
self._save_current_form()
self.state_store.update_step("correction", variant="integrate")
self.tree.selection_set("correction")
self.tree.see("correction")
def _select_refaire_correction(self) -> None:
"""Return from manual conflict resolution to the main redo correction."""
if self.runner.running or self.active_step_id or not self.state_store.evaluation:
+9 -5
View File
@@ -29,7 +29,8 @@ c{43}2x : couper la source à 43 %, conserver la fin et remplacer la cible par l
Cut permet de placer la coupe, avec accrochage entre les pages. Entrée ferme l’aperçu et affiche la commande à recopier ci-dessus : aucun fichier n’est modifié. Une séparation entre pages conserve les pages entières. La source complète est archivée en _old ; les deux labels modifiés sont générés en _new et ajoutés à refaire.json.
Pour les fusions : « Source x> Cible| » place la cible avant la source ; « Source x> |Cible » place la source avant la cible. Même règle avec -> et c{…}1>/c{…}2> (pour la partie transférée) ; sans |, la cible vient en premier.
Vous pouvez changer l’opérateur, déplacer |, corriger les labels ou retirer une instruction. Les lignes vides et celles commençant par ### sont ignorées. Retirer/commenter une ligne ne résout pas son conflit.
Si le PDF cible n’existe pas, il est considéré comme vide : la source (ou la partie transférée après une coupe) devient le nouveau PDF cible.
Vous pouvez changer l’opérateur, déplacer |, corriger les labels ou retirer une instruction. Les lignes vides et celles commençant par ### sont ignorées. Retirer/commenter une ligne signifie conserver les PDF sans modification et considérer ce conflit comme résolu après Exécuter. Exécutez cette étape même si vous avez retiré toutes les instructions : ces conflits ne seront plus proposés lors de la reprise avec refaire.json.
Enregistrez dans l’éditeur, puis cliquez sur Recharger et enfin sur Exécuter. Seul le fichier enregistré est appliqué. L’archivage utilise le suffixe _old ; les PDF créés utilisent _new. Après succès, manual_resolutions.txt est supprimé et correction.json est mis à jour. Si des PDF sont créés, refaire.json est généré : relancez la correction avec --refaire."""
@@ -46,7 +47,10 @@ class ManualResolutionPanel(ttk.Frame):
actions, text="Ouvrir dans un éditeur de texte", command=self.open_editor
)
self.editor_button.pack(side="left")
ttk.Button(actions, text="Recharger", command=self.reload).pack(side="left", padx=6)
self.reload_button = ttk.Button(
actions, text="Recharger", command=self.reload
)
self.reload_button.pack(side="left", padx=6)
self.cut_result = tk.StringVar()
result = ttk.Frame(self)
result.pack(fill="x", pady=4)
@@ -116,10 +120,10 @@ class ManualResolutionPanel(ttk.Frame):
else:
if instructions:
instruction = instructions[0]
for title, label in (("PDF source", instruction.old_label),
("PDF cible", instruction.new_label)):
for title, label in (("Source", instruction.old_label),
("Cible", instruction.new_label)):
button = ttk.Button(
self.text, text=title,
self.text, text=f"{title} : {label}",
command=lambda label=label, copy_id=instruction.copy_id, root=path.parent: self.open_pdf(root, copy_id, label),
)
self.pdf_buttons.append(button)
+60 -5
View File
@@ -150,6 +150,56 @@ class GuiConvenienceTests(unittest.TestCase):
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_integration_preserves_batch_statuses_but_invalidates_downstream_steps(self):
for variant in ("integrate", "batch"):
with self.subTest(variant=variant):
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=[]):
self.app._run_current_step()
self.assertEqual(self.app.active_step_id, "correction")
for step_id, status in statuses.items():
expected = status if variant == "integrate" and step_id != "annotation" else "stale"
self.assertEqual(self.app.state_store.step(step_id)["status"], expected)
self.app.active_step_id = None
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()
@@ -222,19 +272,24 @@ class GuiConvenienceTests(unittest.TestCase):
self.app._move_selection_from("correction", 1)
select.assert_called_once_with("manual_resolution")
def test_labels_and_correction_notify_once_on_success(self):
for step_id in ("labels", "correction"):
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("labels" if step_id == "labels" else "correction",
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"):
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"),
+28 -4
View File
@@ -19,6 +19,7 @@ from PIL import Image
from pypdf import PdfReader, PdfWriter
from copienator import (
CliError,
EvaluationWorkspace,
WorkspaceNotFoundError,
WorkspaceValidationError,
@@ -2058,7 +2059,23 @@ class StandardCliTests(unittest.TestCase):
self.assertEqual(groups, [["Ex 1"], ["Ex 2"]])
editor.assert_not_called()
def test_grouped_batching_does_not_split_one_student(self) -> None:
def test_grouped_batching_looks_ahead_to_keep_below_the_hard_limit(self) -> None:
module = self.modules["annotating_by_label"]
large = Image.new("RGB", (10, 80), "white")
small = Image.new("RGB", (10, 15), "white")
rendered = [
("01", "Ex 1", large, 0, []),
("02", "Ex 1", small, 0, []),
("02", "Ex 2", small, 0, []),
]
with patch.object(module, "MAX_HEIGHT_PX", 100):
batches = module.split_batches(rendered)
self.assertEqual([len(batch) for batch in batches], [1, 2])
self.assertTrue(
all(sum(item[2].height for item in batch) <= 100 for batch in batches)
)
def test_grouped_batching_never_splits_one_student(self) -> None:
module = self.modules["annotating_by_label"]
image = Image.new("RGB", (10, 60), "white")
rendered = [
@@ -2066,9 +2083,16 @@ class StandardCliTests(unittest.TestCase):
("01", "Ex 2", image, 0, []),
("02", "Ex 1", image, 0, []),
]
with patch.object(module, "MAX_HEIGHT_PX", 100):
batches = module.split_batches(rendered)
self.assertEqual([len(batch) for batch in batches], [2, 1])
with patch.object(module, "MAX_HEIGHT_PX", 100), self.assertRaises(
CliError
) as raised:
module.split_batches(rendered)
self.assertIn("Copie01", str(raised.exception))
self.assertIn("cannot be split", str(raised.exception))
def test_grouped_annotation_height_limit_is_32000_pixels(self) -> None:
module = self.modules["annotating_by_label"]
self.assertEqual(module.MAX_HEIGHT_PX, 32000)
class WorkflowTests(unittest.TestCase):
+19 -1
View File
@@ -38,13 +38,31 @@ class ManualResolutionPanelTests(unittest.TestCase):
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],
["PDF source", "PDF cible"] * 2)
["Source : Ex 1", "Cible : Ex 2",
"Source : Ex 3", "Cible : Ex 4"])
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)])
def test_reload_updates_the_displayed_target_and_its_pdf_action(self):
self.path.write_text("Copie01 A -> B|\n", encoding="utf-8")
copies = self.evaluation / "Copies" / "Copie01"
copies.mkdir(parents=True)
(copies / "A.pdf").touch()
(copies / "C.pdf").touch()
panel = self.panel()
self.path.write_text("Copie01 A -> C|\n", encoding="utf-8")
panel.reload_button.invoke()
self.assertEqual(panel.text.get("1.0", "end-1c"), "Copie01 A -> C|\n")
self.assertEqual(panel.pdf_buttons[1].cget("text"), "Cible : C")
with patch("copienator_gui.manual_resolution.open_path") as opened:
panel.pdf_buttons[1].invoke()
opened.assert_called_once_with(copies / "C.pdf")
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()
+102 -1
View File
@@ -6,11 +6,40 @@ from unittest.mock import Mock, patch
import pymupdf
from copienator import EvaluationWorkspace, atomic_write_json, read_json
from copienator import CliError, EvaluationWorkspace, atomic_write_json, read_json
from copienator.commands import correction, resolve_manual
from copienator_gui.app import has_manual_conflicts
class ManualResolutionStateTests(unittest.TestCase):
def test_merge_into_missing_target_uses_source_and_schedules_redo(self):
for operator in ("->", "x>"):
for target in ("B|", "|B"):
with self.subTest(operator=operator, target=target), tempfile.TemporaryDirectory() as temporary:
workspace = self.fixture(Path(temporary), operator)
copies = workspace.copies_dir / "Copie01"
(copies / "B.pdf").unlink()
workspace.manual_resolutions_file.write_text(f"Copie01 A {operator} {target}\n")
resolve_manual.resolve_manual(workspace)
with pymupdf.open(copies / "B_new.pdf") as document:
self.assertEqual(len(document), 1)
self.assertIn("A", document[0].get_text())
self.assertEqual((copies / "A_old.pdf").exists(), operator == "x>")
self.assertEqual((copies / "A.pdf").exists(), operator == "->")
self.assertFalse((copies / "B_old.pdf").exists())
self.assertEqual(read_json(workspace.refaire_file), [["Copie01", ["B"]]])
self.assertNotIn("delayed", read_json(workspace.correction_file)["A"][0][0]["result"])
def test_merge_still_requires_source_without_changing_state(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = self.fixture(Path(temporary), "x>")
(workspace.copies_dir / "Copie01" / "A.pdf").unlink()
before = workspace.correction_file.read_bytes()
with self.assertRaises(CliError):
resolve_manual.resolve_manual(workspace)
self.assertEqual(workspace.correction_file.read_bytes(), before)
self.assertTrue(workspace.manual_resolutions_file.exists())
def test_acknowledgement_clears_only_this_target_and_copy(self):
for error in ("wrg-lbl:B?", "wrg-lbl:B?delayed", "wrg-lbl:B?exists", "al:(->)B?(->)C?", "al:(delayed)B"):
with self.subTest(error=error):
@@ -63,3 +92,75 @@ class ManualResolutionStateTests(unittest.TestCase):
self.assertEqual(correction.run_configured(args), 0)
self.assertFalse(workspace.manual_resolutions_file.exists())
self.assertNotIn("delayed", correction.results["A"][0][0]["result"])
def test_deleted_conflict_stays_resolved_when_other_resolution_generates_refaire(self):
for kind in ("wrong-label", "add-label"):
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as temporary:
workspace = self.fixture(Path(temporary), "x>")
other = workspace.copies_dir / "Copie02"
other.mkdir()
for label in ("A", "B"):
(other / f"{label}.pdf").write_bytes(
(workspace.copies_dir / "Copie01" / f"{label}.pdf").read_bytes())
before = {path.name: path.read_bytes() for path in other.iterdir()}
results = read_json(workspace.correction_file)
results["A"][0].append({"id": "02", "result": {
"error": "wrg-lbl:B?" if kind == "wrong-label" else "al:(->)B?",
"delayed": [[kind, "B"]], "score": 3,
}})
atomic_write_json(workspace.correction_file, results)
# Only Copie01 remains in the edited file; Copie02 was deleted.
resolve_manual.resolve_manual(workspace)
self.assertEqual(read_json(workspace.refaire_file), [["Copie01", ["B"]]])
self.assertEqual(before, {path.name: path.read_bytes() for path in other.iterdir()})
saved = read_json(workspace.correction_file)["A"][0][1]["result"]
self.assertNotIn("delayed", saved)
self.assertEqual(saved["score"], 3)
self.assertNotIn("B?", saved["error"])
(workspace.groups_dir / "B").mkdir(parents=True)
args = correction.build_parser().parse_args([str(workspace.root), "--refaire"])
correction.configure_runtime(workspace, [], args, api_client=Mock())
with patch.object(correction.grouping, "get_pdf_height", return_value=400), \
patch.object(correction.grouping, "create_jpg"), \
patch.object(correction, "process_single_task", return_value=[]):
self.assertEqual(correction.run_configured(args), 0)
self.assertFalse(workspace.manual_resolutions_file.exists())
self.assertNotIn("delayed", correction.results["A"][0][1]["result"])
def test_deleting_all_lines_can_be_applied_and_survives_resume(self):
for text in ("", "### Instructions\n", "### Copie01 A x> B|\n"):
with self.subTest(text=text), tempfile.TemporaryDirectory() as temporary:
workspace = self.fixture(Path(temporary), "x>")
source = workspace.copies_dir / "Copie01"
before = {path.name: path.read_bytes() for path in source.iterdir()}
workspace.manual_resolutions_file.write_text(text)
self.assertTrue(has_manual_conflicts(workspace.manual_resolutions_file))
resolve_manual.resolve_manual(workspace)
self.assertEqual(before, {path.name: path.read_bytes() for path in source.iterdir()})
self.assertFalse(has_manual_conflicts(workspace.manual_resolutions_file))
atomic_write_json(workspace.refaire_file, [])
args = correction.build_parser().parse_args([str(workspace.root), "--refaire"])
correction.configure_runtime(workspace, [], args, api_client=Mock())
self.assertEqual(correction.run_configured(args), 0)
self.assertFalse(workspace.manual_resolutions_file.exists())
def test_invalid_remaining_instruction_does_not_commit_deletions(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = self.fixture(Path(temporary), "x>")
before = workspace.correction_file.read_bytes()
workspace.manual_resolutions_file.write_text("invalid instruction\n")
with self.assertRaises(CliError):
resolve_manual.resolve_manual(workspace)
self.assertEqual(workspace.correction_file.read_bytes(), before)
def test_generated_first_comment_documents_deleting_lines(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = self.fixture(Path(temporary), "x>")
atomic_write_json(workspace.refaire_file, [])
args = correction.build_parser().parse_args([str(workspace.root), "--refaire"])
correction.configure_runtime(workspace, [], args, api_client=Mock())
self.assertEqual(correction.run_configured(args), 0)
first_line = workspace.manual_resolutions_file.read_text().splitlines()[0]
self.assertTrue(first_line.startswith("###"))
self.assertIn("delete a line", first_line)
self.assertIn("resolve-manual", first_line)