32k pixel as hard group limit, better manual resolution support
This commit is contained in:
1 parent
a9c0703f03
commit
5009831c7c
9 files changed
+316
-52
No files matched your search
@@ -27,7 +27,7 @@ from copienator.commands import annotating, annotating_with_checks
|
|||||||
from copienator.filesystem import staged_directory
|
from copienator.filesystem import staged_directory
|
||||||
from copienator.utils import natural_key
|
from copienator.utils import natural_key
|
||||||
|
|
||||||
MAX_HEIGHT_PX = 25000
|
MAX_HEIGHT_PX = 32000
|
||||||
|
|
||||||
|
|
||||||
def render_item(item):
|
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)")
|
print(f"Generating group PDF: {prefix} G{group_id} ({len(batch)} elements)")
|
||||||
max_width = max(item[2].width for item in batch)
|
max_width = max(item[2].width for item in batch)
|
||||||
total_height = sum(item[2].height 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")
|
concatenated = Image.new("RGB", (max_width, total_height), "white")
|
||||||
draw = ImageDraw.Draw(concatenated)
|
draw = ImageDraw.Draw(concatenated)
|
||||||
checkbox_map: list[dict[str, Any]] = []
|
checkbox_map: list[dict[str, Any]] = []
|
||||||
@@ -268,32 +273,44 @@ def _existing_group_state(
|
|||||||
|
|
||||||
|
|
||||||
def split_batches(rendered):
|
def split_batches(rendered):
|
||||||
def split(maximum_height: float):
|
|
||||||
batches = []
|
batches = []
|
||||||
current = []
|
current = []
|
||||||
current_height = 0
|
current_height = 0
|
||||||
previous_student = None
|
|
||||||
for item in rendered:
|
def flush() -> None:
|
||||||
student_id = item[0]
|
nonlocal current, current_height
|
||||||
image_height = item[2].height
|
if current:
|
||||||
if (
|
|
||||||
current
|
|
||||||
and current_height + image_height > maximum_height
|
|
||||||
and student_id != previous_student
|
|
||||||
):
|
|
||||||
batches.append(current)
|
batches.append(current)
|
||||||
current = []
|
current = []
|
||||||
current_height = 0
|
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)
|
index = 0
|
||||||
relaxed = split(1.1 * MAX_HEIGHT_PX)
|
while index < len(rendered):
|
||||||
return relaxed if len(relaxed) < len(strict) else strict
|
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(
|
def _generate_groups(
|
||||||
|
|||||||
@@ -1026,7 +1026,9 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
|
|||||||
manual_path = INPUT_DIR / "manual_resolutions.txt"
|
manual_path = INPUT_DIR / "manual_resolutions.txt"
|
||||||
atomic_write_text(
|
atomic_write_text(
|
||||||
manual_path,
|
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".join(unresolved_delayed)
|
||||||
+ "\n",
|
+ "\n",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -132,6 +132,18 @@ def set_suffix_and_clean_error(
|
|||||||
result["error"] = 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:
|
def get_actual_pdf(copies_dir: Path, copy_id: str, label: str) -> Path:
|
||||||
base = copies_dir / f"Copie{copy_id}" / f"{label}.pdf"
|
base = copies_dir / f"Copie{copy_id}" / f"{label}.pdf"
|
||||||
for candidate in (
|
for candidate in (
|
||||||
@@ -157,13 +169,7 @@ def _validate_pdf_inputs(
|
|||||||
missing: set[Path] = set()
|
missing: set[Path] = set()
|
||||||
for instruction in instructions:
|
for instruction in instructions:
|
||||||
source = initial_paths[(instruction.copy_id, instruction.old_label)]
|
source = initial_paths[(instruction.copy_id, instruction.old_label)]
|
||||||
destination = initial_paths[(instruction.copy_id, instruction.new_label)]
|
if (instruction.should_merge or instruction.should_copy) and not source.exists():
|
||||||
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():
|
|
||||||
missing.add(source)
|
missing.add(source)
|
||||||
if missing:
|
if missing:
|
||||||
rendered = ", ".join(str(path) for path in sorted(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")
|
raise CliError("correction.json must contain a JSON object")
|
||||||
results: dict[str, Any] = loaded
|
results: dict[str, Any] = loaded
|
||||||
instructions = parse_instructions(workspace.manual_resolutions_file)
|
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]
|
cut_sources = [(item.copy_id, item.old_label) for item in instructions if item.cut]
|
||||||
if len(set(cut_sources)) != len(cut_sources):
|
if len(set(cut_sources)) != len(cut_sources):
|
||||||
raise CliError("Une seule coupe par label source est autorisée dans une résolution.")
|
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:
|
try:
|
||||||
if instruction.pipe_first:
|
if instruction.pipe_first:
|
||||||
writer.append(source)
|
writer.append(source)
|
||||||
|
if destination.exists():
|
||||||
writer.append(destination)
|
writer.append(destination)
|
||||||
else:
|
else:
|
||||||
|
if destination.exists():
|
||||||
writer.append(destination)
|
writer.append(destination)
|
||||||
writer.append(source)
|
writer.append(source)
|
||||||
writer.write(temp_output)
|
writer.write(temp_output)
|
||||||
@@ -285,6 +297,9 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
|
|||||||
if temporary not in used_temps:
|
if temporary not in used_temps:
|
||||||
temporary.unlink(missing_ok=True)
|
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)
|
atomic_write_json(workspace.correction_file, results)
|
||||||
refaire_tasks = [[copy_name, labels] for copy_name, labels in refaire_by_copy.items()]
|
refaire_tasks = [[copy_name, labels] for copy_name, labels in refaire_by_copy.items()]
|
||||||
if refaire_tasks:
|
if refaire_tasks:
|
||||||
@@ -292,6 +307,8 @@ def resolve_manual(workspace: EvaluationWorkspace) -> ExitCode:
|
|||||||
workspace.manual_resolutions_file.unlink()
|
workspace.manual_resolutions_file.unlink()
|
||||||
|
|
||||||
print("Manual resolutions successfully applied.")
|
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:
|
if refaire_tasks:
|
||||||
print(
|
print(
|
||||||
f"File {workspace.refaire_file.name} generated. Run "
|
f"File {workspace.refaire_file.name} generated. Run "
|
||||||
|
|||||||
+34
-8
@@ -10,7 +10,8 @@ from pathlib import Path
|
|||||||
from tkinter import filedialog, messagebox, ttk
|
from tkinter import filedialog, messagebox, ttk
|
||||||
from typing import Any
|
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.copy_errors import copy_errors
|
||||||
from copienator.filesystem import staged_files
|
from copienator.filesystem import staged_files
|
||||||
from copienator.platform import (
|
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
|
# If the file cannot be inspected, keep the step visible rather than
|
||||||
# silently claiming that there is nothing to resolve.
|
# silently claiming that there is nothing to resolve.
|
||||||
return True
|
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):
|
class CopienatorApp(tk.Tk):
|
||||||
@@ -1459,6 +1467,10 @@ class CopienatorApp(tk.Tk):
|
|||||||
self._save_current_form()
|
self._save_current_form()
|
||||||
run_values = self._values()
|
run_values = self._values()
|
||||||
ordered_ids = self._progression_ids(step.id)
|
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.invalidate_after(ordered_ids, step.id)
|
||||||
self.state_store.update_step(
|
self.state_store.update_step(
|
||||||
step.id,
|
step.id,
|
||||||
@@ -1728,11 +1740,14 @@ class CopienatorApp(tk.Tk):
|
|||||||
"sur « Marquer terminée »."
|
"sur « Marquer terminée »."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if step_id in {"labels", "correction"} and self.state_store.evaluation:
|
notified_completions = {
|
||||||
completion = (
|
"labels": "détection des labels terminée",
|
||||||
"détection des labels terminée"
|
"correction": "étape de correction terminée",
|
||||||
if step_id == "labels" else "é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()
|
outcome = STATUS_LABELS[status].lower()
|
||||||
self._notify_completion(
|
self._notify_completion(
|
||||||
f"Copienator — {completion} ({outcome})",
|
f"Copienator — {completion} ({outcome})",
|
||||||
@@ -1744,7 +1759,9 @@ class CopienatorApp(tk.Tk):
|
|||||||
self.batch_monitor.stop()
|
self.batch_monitor.stop()
|
||||||
self._batch_results_ready()
|
self._batch_results_ready()
|
||||||
return
|
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)
|
self.after_idle(self._select_refaire_correction)
|
||||||
else:
|
else:
|
||||||
self.after_idle(
|
self.after_idle(
|
||||||
@@ -1842,6 +1859,15 @@ class CopienatorApp(tk.Tk):
|
|||||||
self.tree.selection_set(ids[target])
|
self.tree.selection_set(ids[target])
|
||||||
self.tree.see(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:
|
def _select_refaire_correction(self) -> None:
|
||||||
"""Return from manual conflict resolution to the main redo correction."""
|
"""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:
|
if self.runner.running or self.active_step_id or not self.state_store.evaluation:
|
||||||
|
|||||||
@@ -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.
|
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.
|
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."""
|
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
|
actions, text="Ouvrir dans un éditeur de texte", command=self.open_editor
|
||||||
)
|
)
|
||||||
self.editor_button.pack(side="left")
|
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()
|
self.cut_result = tk.StringVar()
|
||||||
result = ttk.Frame(self)
|
result = ttk.Frame(self)
|
||||||
result.pack(fill="x", pady=4)
|
result.pack(fill="x", pady=4)
|
||||||
@@ -116,10 +120,10 @@ class ManualResolutionPanel(ttk.Frame):
|
|||||||
else:
|
else:
|
||||||
if instructions:
|
if instructions:
|
||||||
instruction = instructions[0]
|
instruction = instructions[0]
|
||||||
for title, label in (("PDF source", instruction.old_label),
|
for title, label in (("Source", instruction.old_label),
|
||||||
("PDF cible", instruction.new_label)):
|
("Cible", instruction.new_label)):
|
||||||
button = ttk.Button(
|
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),
|
command=lambda label=label, copy_id=instruction.copy_id, root=path.parent: self.open_pdf(root, copy_id, label),
|
||||||
)
|
)
|
||||||
self.pdf_buttons.append(button)
|
self.pdf_buttons.append(button)
|
||||||
|
|||||||
@@ -150,6 +150,56 @@ class GuiConvenienceTests(unittest.TestCase):
|
|||||||
self.app.update()
|
self.app.update()
|
||||||
self.assertEqual(self.app.current_step.id, "fetch_batches")
|
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):
|
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.state_store.update_step("correction", variant="batch", last_run_variant="batch")
|
||||||
self.app._populate_tree()
|
self.app._populate_tree()
|
||||||
@@ -222,19 +272,24 @@ class GuiConvenienceTests(unittest.TestCase):
|
|||||||
self.app._move_selection_from("correction", 1)
|
self.app._move_selection_from("correction", 1)
|
||||||
select.assert_called_once_with("manual_resolution")
|
select.assert_called_once_with("manual_resolution")
|
||||||
|
|
||||||
def test_labels_and_correction_notify_once_on_success(self):
|
def test_long_running_steps_notify_once_on_success(self):
|
||||||
for step_id in ("labels", "correction"):
|
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:
|
with self.subTest(step=step_id), patch("copienator_gui.app.notify_desktop") as notify:
|
||||||
self.app.active_step_id = step_id
|
self.app.active_step_id = step_id
|
||||||
self.app._finish_process(0, False)
|
self.app._finish_process(0, False)
|
||||||
self.app._finish_process(0, False)
|
self.app._finish_process(0, False)
|
||||||
notify.assert_called_once()
|
notify.assert_called_once()
|
||||||
self.assertIn(self.evaluation.name, notify.call_args.args[1])
|
self.assertIn(self.evaluation.name, notify.call_args.args[1])
|
||||||
self.assertIn("labels" if step_id == "labels" else "correction",
|
self.assertIn(completion_text, notify.call_args.args[1])
|
||||||
notify.call_args.args[1])
|
|
||||||
|
|
||||||
def test_incomplete_failed_and_interrupted_steps_notify_the_outcome_once(self):
|
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 (
|
for code, interrupted, outcome in (
|
||||||
(4, False, "partielle"), (1, False, "échouée"),
|
(4, False, "partielle"), (1, False, "échouée"),
|
||||||
(0, True, "interrompue"), (130, False, "interrompue"),
|
(0, True, "interrompue"), (130, False, "interrompue"),
|
||||||
|
|||||||
+28
-4
@@ -19,6 +19,7 @@ from PIL import Image
|
|||||||
from pypdf import PdfReader, PdfWriter
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
|
||||||
from copienator import (
|
from copienator import (
|
||||||
|
CliError,
|
||||||
EvaluationWorkspace,
|
EvaluationWorkspace,
|
||||||
WorkspaceNotFoundError,
|
WorkspaceNotFoundError,
|
||||||
WorkspaceValidationError,
|
WorkspaceValidationError,
|
||||||
@@ -2058,7 +2059,23 @@ class StandardCliTests(unittest.TestCase):
|
|||||||
self.assertEqual(groups, [["Ex 1"], ["Ex 2"]])
|
self.assertEqual(groups, [["Ex 1"], ["Ex 2"]])
|
||||||
editor.assert_not_called()
|
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"]
|
module = self.modules["annotating_by_label"]
|
||||||
image = Image.new("RGB", (10, 60), "white")
|
image = Image.new("RGB", (10, 60), "white")
|
||||||
rendered = [
|
rendered = [
|
||||||
@@ -2066,9 +2083,16 @@ class StandardCliTests(unittest.TestCase):
|
|||||||
("01", "Ex 2", image, 0, []),
|
("01", "Ex 2", image, 0, []),
|
||||||
("02", "Ex 1", image, 0, []),
|
("02", "Ex 1", image, 0, []),
|
||||||
]
|
]
|
||||||
with patch.object(module, "MAX_HEIGHT_PX", 100):
|
with patch.object(module, "MAX_HEIGHT_PX", 100), self.assertRaises(
|
||||||
batches = module.split_batches(rendered)
|
CliError
|
||||||
self.assertEqual([len(batch) for batch in batches], [2, 1])
|
) 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):
|
class WorkflowTests(unittest.TestCase):
|
||||||
|
|||||||
@@ -38,13 +38,31 @@ class ManualResolutionPanelTests(unittest.TestCase):
|
|||||||
self.assertEqual(panel.text.get("1.0", "end-1c"), content)
|
self.assertEqual(panel.text.get("1.0", "end-1c"), content)
|
||||||
self.assertEqual(len(panel.pdf_buttons), 4)
|
self.assertEqual(len(panel.pdf_buttons), 4)
|
||||||
self.assertEqual([button.cget("text") for button in panel.pdf_buttons],
|
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:
|
with patch("copienator_gui.manual_resolution.open_path") as opened:
|
||||||
panel.editor_button.invoke()
|
panel.editor_button.invoke()
|
||||||
for button in panel.pdf_buttons:
|
for button in panel.pdf_buttons:
|
||||||
button.invoke()
|
button.invoke()
|
||||||
self.assertEqual(opened.call_args_list, [call(self.path), *map(call, paths)])
|
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):
|
def test_reload_keeps_invalid_lines_visible_and_removes_stale_actions(self):
|
||||||
self.path.write_text("Copie01 A -> B|\n", encoding="utf-8")
|
self.path.write_text("Copie01 A -> B|\n", encoding="utf-8")
|
||||||
panel = self.panel()
|
panel = self.panel()
|
||||||
|
|||||||
@@ -6,11 +6,40 @@ from unittest.mock import Mock, patch
|
|||||||
|
|
||||||
import pymupdf
|
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.commands import correction, resolve_manual
|
||||||
|
from copienator_gui.app import has_manual_conflicts
|
||||||
|
|
||||||
|
|
||||||
class ManualResolutionStateTests(unittest.TestCase):
|
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):
|
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"):
|
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):
|
with self.subTest(error=error):
|
||||||
@@ -63,3 +92,75 @@ class ManualResolutionStateTests(unittest.TestCase):
|
|||||||
self.assertEqual(correction.run_configured(args), 0)
|
self.assertEqual(correction.run_configured(args), 0)
|
||||||
self.assertFalse(workspace.manual_resolutions_file.exists())
|
self.assertFalse(workspace.manual_resolutions_file.exists())
|
||||||
self.assertNotIn("delayed", correction.results["A"][0][0]["result"])
|
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)
|
||||||
Reference in new issue
Block a user