Compare commits

..
2 Commits
Author SHA1 Message Date
sebastien 1959c5d3bb Bnot : split large copies into pieces. 2026-10-23 08:32:17 +02:00
sebastien 834ffe32f2 miscs 2026-10-23 08:24:10 +02:00
17 changed files with 433 additions and 50 deletions

No files matched your search

+11
View File
@@ -235,6 +235,13 @@ OU
=python -m copienator annotate-checks Interro/Copies/Copie01.pdf=.
Le mode =--refaire= exige un fichier =refaire.json= et écrit dans
=BRnot=.
Les documents sont limités à 32 000 pixels de haut, comme pour =BGnot=.
Les grandes copies sont découpées entre les labels en =Part1=, =Part2=,
etc., dans leur dossier =CopieXX=. L'export les nomme =CopieXX Part1.pdf=,
etc. Importer toutes les parties avant de lancer =read-annotations= :
elles sont réunies dans les résultats habituels de la copie. Une réponse
individuelle dépassant la limite produit une erreur explicite.
OU
2. =python -m copienator annotate-grouped Interro= dans =BGnot=
@@ -242,6 +249,10 @@ OU
réponses par question.
Enregistrées dans =BGnot=
Les groupes visent une hauteur maximale de 32 000 pixels. Si le bloc
d'un seul élève dépasse cette limite, il reste entier dans un groupe
dédié, même lorsqu'il ne contient qu'une seule réponse.
_Needs_ : label_groups file (made automatically by this function),
qui dit quelles questions regrouper.
+27
View File
@@ -0,0 +1,27 @@
"""Locate tablet documents, including the parts of a large checked copy."""
from pathlib import Path
from copienator.json_io import read_json
MAX_HEIGHT_PX = 32000
def copy_documents(directory: Path) -> list[Path]:
try:
metadata = read_json(directory / "bnote.json", default={})
except (OSError, ValueError):
# Ordinary documents need no manifest. Let the reader report bad metadata.
return [directory]
parts = metadata.get("parts", []) if isinstance(metadata, dict) else []
return [directory / name for name in parts] if parts else [directory]
def tablet_documents(root: Path) -> list[tuple[str, Path]]:
documents = []
for directory in sorted(root.iterdir()):
if not directory.is_dir():
continue
for part in copy_documents(directory):
name = directory.name if part == directory else f"{directory.name} {part.name}"
documents.append((name, part))
return documents
+14 -6
View File
@@ -416,6 +416,7 @@ def compose_label_image(base_img, label, result, hmin,
# Draw Headers
current_y = 0
draw = ImageDraw.Draw(final_img, "RGBA")
callback_items = []
for el in header_elements:
if el["type"] == "score" and more_right:
@@ -426,9 +427,9 @@ def compose_label_image(base_img, label, result, hmin,
if draw_callback:
# Hook for checkboxes
draw_callback("header_item", draw,
callback_items.append(("header_item",
{"x": 0, "y": current_y, "w": el["img"].width, "h": el["img"].height},
el)
el))
current_y += el["img"].height
# Paste Base Image
@@ -453,9 +454,9 @@ def compose_label_image(base_img, label, result, hmin,
draw.rectangle([target_xmin, target_ymin, target_xmax, target_ymax], outline="red", width=3)
if draw_callback:
draw_callback("local_rect", draw,
callback_items.append(("local_rect",
{"box": [target_xmin, target_ymin, target_xmax, target_ymax]},
{"data": fb, "index": idx})
{"data": fb, "index": idx}))
# Render Text
txt_img = render_fn(fb['text'], width_px=ANNOT_WIDTH,
@@ -481,12 +482,19 @@ def compose_label_image(base_img, label, result, hmin,
final_img.paste(txt_img, (10, int(paste_y)), mask=txt_img)
if draw_callback:
draw_callback("local_text", draw,
callback_items.append(("local_text",
{"x": 10, "y": int(paste_y), "w": txt_img.width, "h": txt_img.height},
{"data": fb, "index": idx})
{"data": fb, "index": idx}))
last_text_bottom = paste_y + txt_img.height
# Keep checkbox interiors clear of later rectangles and comment images.
# Use the final canvas, which may have grown while placing comments.
if draw_callback:
draw = ImageDraw.Draw(final_img, "RGBA")
for kind, pos, meta in callback_items:
draw_callback(kind, draw, pos, meta)
final_img.info['annotation_layout'] = layout
return final_img, header_height
+8 -10
View File
@@ -23,13 +23,12 @@ from copienator import (
workspace_from_args,
)
from copienator.annotation_data import load_annotation_data
from copienator.annotation_documents import MAX_HEIGHT_PX
from copienator.commands import annotating, annotating_with_checks
from copienator.commands.import_annotations import clear_import_directory
from copienator.filesystem import staged_directory
from copienator.utils import natural_key
MAX_HEIGHT_PX = 32000
def render_item(item):
student_id, label, content = item
@@ -65,10 +64,10 @@ 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:
if total_height > MAX_HEIGHT_PX and len({item[0] for item in batch}) > 1:
raise CliError(
f"{prefix} G{group_id} would be {total_height} px high; "
f"the maximum is {MAX_HEIGHT_PX} px."
f"only a single student's block may exceed {MAX_HEIGHT_PX} px."
)
concatenated = Image.new("RGB", (max_width, total_height), "white")
draw = ImageDraw.Draw(concatenated)
@@ -293,12 +292,11 @@ def split_batches(rendered):
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."
)
# Oversized blocks remain intact in a group of their own.
batches.append(list(student_items))
heights.append(student_height)
index = end
continue
# Reuse space in earlier groups, keeping each student's block intact.
for batch_index, height in enumerate(heights):
+51 -9
View File
@@ -26,6 +26,7 @@ from copienator import (
workspace_from_target,
)
from copienator.annotation_data import load_annotation_data
from copienator.annotation_documents import MAX_HEIGHT_PX, copy_documents
from copienator.commands import annotating
from copienator.commands.import_annotations import clear_import_directory
from copienator.filesystem import staged_directory
@@ -143,7 +144,11 @@ class CheckboxRenderer:
def _output_complete(output_dir: Path) -> bool:
return all((output_dir / name).is_file() for name in EXPECTED_OUTPUTS)
return all(
(document / name).is_file()
for document in copy_documents(output_dir)
for name in EXPECTED_OUTPUTS
)
def _render_student(
@@ -197,7 +202,6 @@ def _render_student(
"label": label,
"header_height": header_height,
"layout": final_image.info.get('annotation_layout', annotating.layout_for(None)),
"img_h": final_image.height,
}
)
@@ -205,6 +209,46 @@ def _render_student(
print(f"Warning: no annotations could be rendered for Copie{student_id}")
return "partial"
batches = []
start = 0
height = 0
for index, image in enumerate(label_images):
if image.height > MAX_HEIGHT_PX:
raise CliError(
f"Copie{student_id} ({bnote_entries[index]['label']}) is "
f"{image.height} px high; a single answer cannot be split and "
f"the maximum document height is {MAX_HEIGHT_PX} px."
)
if height + image.height > MAX_HEIGHT_PX:
batches.append((start, index))
start = index
height = 0
height += image.height
batches.append((start, len(label_images)))
with staged_directory(output_dir) as staging:
parts = []
for index, (start, end) in enumerate(batches, start=1):
destination = staging
if len(batches) > 1:
name = f"Part{index}"
parts.append(name)
destination /= name
_save_document(destination, label_images[start:end],
checkbox_groups[start:end], bnote_entries[start:end])
if parts:
atomic_write_json(staging / "bnote.json", {
"parts": parts, "images": bnote_entries,
})
return "partial" if problems else "success"
def _save_document(
output_dir: Path,
label_images: list[Image.Image],
checkbox_groups: list[list[dict[str, Any]]],
bnote_entries: list[dict[str, Any]],
) -> None:
max_width = max(image.width for image in label_images)
total_height = sum(image.height for image in label_images)
concatenated = Image.new("RGB", (max_width, total_height), "white")
@@ -216,7 +260,6 @@ def _render_student(
concatenated.paste(image, (0, current_y))
bnote_entries[index]["hmin"] = current_y
bnote_entries[index]["hmax"] = current_y + image.height
del bnote_entries[index]["img_h"]
for item in checkboxes:
box = item.get("final_box") or item.get("rel_box")
item["global_box"] = [
@@ -228,15 +271,15 @@ def _render_student(
checkbox_map.append(item)
current_y += image.height
with staged_directory(output_dir) as staging:
output_dir.mkdir(parents=True, exist_ok=True)
atomic_write_json(
staging / "bnote.json",
output_dir / "bnote.json",
{"width": max_width, "height": total_height, "images": bnote_entries},
)
atomic_write_json(staging / "checkboxes.json", checkbox_map)
reference = staging / "Reference.jpg"
atomic_write_json(output_dir / "checkboxes.json", checkbox_map)
reference = output_dir / "Reference.jpg"
concatenated.save(reference, quality=90)
pdf_path = staging / "Concat.pdf"
pdf_path = output_dir / "Concat.pdf"
pdf_canvas = canvas.Canvas(str(pdf_path), pagesize=(max_width, total_height))
pdf_canvas.drawImage(
str(reference),
@@ -246,7 +289,6 @@ def _render_student(
height=total_height,
)
pdf_canvas.save()
return "partial" if problems else "success"
def _copy_id_from_target(workspace: EvaluationWorkspace, target: Path) -> str | None:
+5 -2
View File
@@ -11,6 +11,7 @@ from copienator import (
workspace_from_args,
)
from copienator.configuration import EXPORT_DIR
from copienator.annotation_documents import tablet_documents
from copienator.platform import replace_with_link_or_copy
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
@@ -36,7 +37,9 @@ def export_directory(
subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")]
missing_outputs = 0
for subdir in subdirs:
for name, subdir in tablet_documents(source_dir):
if subdir not in subdirs and subdir.parent not in subdirs:
continue
concat_file = next(
(
candidate
@@ -52,7 +55,7 @@ def export_directory(
)
missing_outputs += 1
continue
destination = sync_dir / f"{prefix}{subdir.name}{concat_file.suffix.lower()}"
destination = sync_dir / f"{prefix}{name}{concat_file.suffix.lower()}"
method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink")
print(f"Exported: {destination} ({method})")
return ExitCode.PARTIAL if missing_outputs else ExitCode.SUCCESS
+6 -3
View File
@@ -13,6 +13,7 @@ from copienator import (
workspace_from_args,
)
from copienator import configuration
from copienator.annotation_documents import tablet_documents
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
IMPORT_DIR = getattr(configuration, "IMPORT_DIR", None)
@@ -58,6 +59,7 @@ def sync_annotated(
return ExitCode.INVALID_WORKSPACE
missing_targets = 0
targets = dict(tablet_documents(annotation_dir))
annotated_files = sorted(
(
path
@@ -70,10 +72,11 @@ def sync_annotated(
if prefix and not annotated_file.stem.startswith(prefix):
print(f"Ignoring return from another pass: {annotated_file.name}")
continue
target_subdir = annotation_dir / annotated_file.stem.removeprefix(prefix)
name = annotated_file.stem.removeprefix(prefix)
target_subdir = targets.get(name)
if not target_subdir.is_dir():
print(f"Warning: directory not found: {target_subdir}", file=sys.stderr)
if target_subdir is None or not target_subdir.is_dir():
print(f"Warning: annotation document not found: {name}", file=sys.stderr)
missing_targets += 1
else:
suffix = annotated_file.suffix.lower()
+28 -3
View File
@@ -22,6 +22,7 @@ from copienator import (
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationData, load_annotation_data
from copienator.annotation_documents import copy_documents
from copienator.annotation_layout import legacy_layout
from copienator.annotation_modifications import (
BASELINE,
@@ -154,6 +155,7 @@ def apply_actions_and_regenerate(
all_labels: list[str],
*,
update_score: bool = False,
label_notes: dict[str, Image.Image] | None = None,
) -> ExitCode:
"""Apply annotations and atomically merge the regenerated student files."""
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
@@ -205,7 +207,7 @@ def apply_actions_and_regenerate(
scores[label] = str(result.get("score", 0))
is_empty = result.get("error") == "empty-answer"
sub_note = None
sub_note = label_notes.get(label) if label_notes is not None else None
if notes_layer is not None and "hmin" in image_info:
hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0))
@@ -325,11 +327,33 @@ def run(
status = ExitCode.PARTIAL
continue
print(f"Processing annotations for: {student_id}")
actions, notes = ([], None) if only_empty else detect_checks_and_notes(output_dir)
if notes is None and not actions and not update_score and not only_empty:
documents = copy_documents(output_dir)
label_notes = None
actions, notes = [], None
if not only_empty and documents == [output_dir]:
actions, notes = detect_checks_and_notes(output_dir)
elif not only_empty:
label_notes = {}
incomplete = False
for document in documents:
part_actions, part_notes = detect_checks_and_notes(document)
if part_notes is None:
incomplete = True
continue
actions.extend(part_actions)
for item in read_json(document / "bnote.json")["images"]:
label_notes[item["label"]] = part_notes.crop(
(0, item["hmin"], part_notes.width, item["hmax"])
)
if incomplete:
print(" Missing a readable annotation part; copy was preserved.")
status = ExitCode.PARTIAL
continue
if notes is None and not label_notes and not actions and not update_score and not only_empty:
print(" No readable annotation input found.")
status = ExitCode.PARTIAL
continue
extra = {"label_notes": label_notes} if label_notes is not None else {}
result = apply_actions_and_regenerate(
workspace,
loaded.data,
@@ -338,6 +362,7 @@ def run(
notes,
all_labels,
update_score=update_score,
**extra,
)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
@@ -25,6 +25,7 @@ from copienator import (
)
from copienator.annotation_actions import apply_checkbox_actions, apply_score_overrides
from copienator.annotation_data import AnnotationData, RefaireList, load_annotation_data
from copienator.annotation_documents import copy_documents, tablet_documents
from copienator.annotation_layout import legacy_layout
from copienator.annotation_modifications import (
BASELINE,
@@ -463,11 +464,10 @@ def _scan_redo_annotations(
incomplete: set[str] = set()
plans = []
required = ("checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
for path in sorted(directory.iterdir()):
if not path.is_dir():
continue
for _name, path in tablet_documents(directory):
copy_name = path.name if path.name.startswith("Copie") else path.parent.name
default_id = (
path.name.removeprefix("Copie") if path.name.startswith("Copie") else None
copy_name.removeprefix("Copie") if copy_name.startswith("Copie") else None
)
try:
metadata = read_json(path / "bnote.json")
@@ -590,12 +590,12 @@ def run(
if annotation_dir == "Bnot":
for student_id in refaire_by_student:
directory = workspace.root / annotation_dir / f"Copie{student_id}"
if directory.is_dir():
for document in copy_documents(directory) if directory.is_dir() else []:
_merge_scan_result(
actions_by_student,
notes_by_student,
_scan_annotation_directory(
directory, default_student_id=student_id
document, default_student_id=student_id
),
)
+21
View File
@@ -2129,6 +2129,9 @@ class CopienatorApp(tk.Tk):
return
if step_id == "fetch_batches":
self.after_idle(self._select_batch_integration)
elif (step_id == "correction"
and self.state_store.step(step_id).get("last_run_variant") == "integrate"):
self.after_idle(self._select_after_batch_integration)
elif step_id == "manual_resolution":
self.after_idle(self._select_refaire_correction)
else:
@@ -2241,6 +2244,24 @@ class CopienatorApp(tk.Tk):
self.tree.selection_set("correction")
self.tree.see("correction")
def _select_after_batch_integration(self) -> None:
"""Continue integration with conflict resolution or correction cleanup."""
evaluation = self.state_store.evaluation
if self.runner.running or self.active_step_id or evaluation is None:
return
if has_manual_conflicts(evaluation / "manual_resolutions.txt"):
target = "manual_resolution"
else:
self.state_store.update_step("manual_resolution", status="success")
self.state_store.add_history({
"step": "manual_resolution", "status": "success",
"manual": False, "reason": "Aucun conflit manuel après l’intégration batch.",
})
self._populate_tree()
target = "post_correction"
self.tree.selection_set(target)
self.tree.see(target)
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:
+3 -3
View File
@@ -80,9 +80,9 @@ class CutHelper(tk.Toplevel):
controls = ttk.Frame(self)
controls.pack(fill="x", padx=8)
ttk.Label(controls, text="Conserver à la source :").pack(side="left")
ttk.Radiobutton(controls, text="1 — début", variable=self.keep, value=1).pack(side="left")
ttk.Radiobutton(controls, text="2 — fin", variable=self.keep, value=2).pack(side="left")
ttk.Radiobutton(controls, text="Ajouter à la cible", variable=self.mode, value=">").pack(side="left")
ttk.Radiobutton(controls, text="1 — début", variable=self.keep, value=1).pack(side="left", padx=(8, 12))
ttk.Radiobutton(controls, text="2 — fin", variable=self.keep, value=2).pack(side="left", padx=(0, 24))
ttk.Radiobutton(controls, text="Ajouter à la cible", variable=self.mode, value=">").pack(side="left", padx=(0, 12))
ttk.Radiobutton(controls, text="Remplacer", variable=self.mode, value="x").pack(side="left")
ttk.Label(self, textvariable=self.caption).pack(anchor="w", padx=8, pady=5)
viewport = ttk.Frame(self)
+7 -1
View File
@@ -193,7 +193,7 @@ class GivingNamesPanel(ttk.LabelFrame):
else "A Rendre n’a pas encore été généré."
)
ttk.Label(self.issue_frame, text=text).grid(
row=0, column=0, columnspan=4, sticky="w"
row=0, column=0, columnspan=5, sticky="w"
)
return
@@ -203,6 +203,9 @@ class GivingNamesPanel(ttk.LabelFrame):
ttk.Label(self.issue_frame, text="Nouveau nom").grid(
row=0, column=1, sticky="w"
)
ttk.Label(self.issue_frame, text="Nom attribué").grid(
row=0, column=4, sticky="w", padx=(12, 0)
)
for row, issue in enumerate(issues, start=1):
ttk.Label(
self.issue_frame,
@@ -226,3 +229,6 @@ class GivingNamesPanel(ttk.LabelFrame):
item, value
),
).grid(row=row, column=3, pady=2)
ttk.Label(self.issue_frame, text=issue.name).grid(
row=row, column=4, sticky="w", padx=(12, 0), pady=2
)
+3 -1
View File
@@ -279,7 +279,9 @@ passage à la suite.
Exécutez lorsque tous les résultats sont disponibles. Vérifiez
l’absence de récupération manquante ; l’interface revient ensuite à
**Lancer ou intégrer la correction**, en mode **Intégrer les résultats
batch**. Exécutez cette intégration.
batch**. Exécutez cette intégration. Après succès, l’interface passe à
**Résoudre les conflits manuels** s’il en reste, ou directement à
**Nettoyer la correction** sinon.
### Résoudre les conflits manuels — si des conflits sont signalés
+21
View File
@@ -12,6 +12,7 @@ from copienator.annotation_layout import (
move_answer_pdf, save_parts,
)
from copienator.commands import annotating, annotating_by_label, splitting_int
from copienator.commands.annotating_with_checks import CheckboxRenderer
from copienator.commands import reading_grouped_annotations as reader
from copienator.left_margin import analyse_margins
from copienator.pdf_cut import split_pdf
@@ -113,6 +114,26 @@ class AnnotationLayoutTests(unittest.TestCase):
self.assertEqual(low.getpixel((400, height + 80)), (0, 0, 255))
self.assertEqual(low.getpixel((399, height + 80)), (255, 255, 255))
def test_later_rectangle_cannot_draw_inside_comment_checkbox(self):
renderer = CheckboxRenderer('Ex 1')
result = {'score': 2, 'feedback': [
{'text': 'First comment', 'box_2d': [10, 50, 50, 200]},
{'text': 'Later rectangle', 'box_2d': [10, 300, 80, 400]},
]}
with patch.object(annotating, 'render_score_text', return_value=Image.new('RGB', (800, 80), 'white')):
image, _ = annotating.compose_label_image(
Image.new('RGB', (1654, 100), 'white'), 'Ex 1', result, 0,
render_fn=lambda *args, **kwargs: Image.new('RGBA', (620, 50), (255, 200, 200, 180)),
draw_callback=renderer.callback, layout=legacy_layout())
# The later rectangle's left edge crosses the first comment's checkbox.
checkbox = next(item for item in renderer.checkboxes
if item['type'] == 'del_local' and item['index'] == 0)
x1, y1, x2, y2 = checkbox['final_box']
interior = image.crop((x1 + 5, y1 + 5, x2 - 5, y2 - 5))
self.assertEqual(interior.getextrema(), ((255, 255),) * 3)
# The rectangle still exists outside the checkbox.
self.assertEqual(image.getpixel((650, 90)), (255, 0, 0))
def test_mixed_bgnot_widths_and_saved_geometry_survive_config_change(self):
normal, height, _ = self.render(legacy_layout())
low, _, _ = self.render({**legacy_layout(), 'margin_left': 400})
+156
View File
@@ -0,0 +1,156 @@
"""Large checked copies survive the tablet round trip as separate documents."""
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageDraw
from copienator import CliError, EvaluationWorkspace, ExitCode, read_json
from copienator.annotation_data import AnnotationLoadResult
from copienator.annotation_documents import MAX_HEIGHT_PX, copy_documents
from copienator.commands import annotating_with_checks as checks
from copienator.commands import export, import_annotations, reading_annotations as reader
from copienator.commands import reading_grouped_annotations as grouped_reader
class CheckedDocumentTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.workspace = EvaluationWorkspace(self.root / 'Exam')
for name in ('Copies', 'Par label'):
(self.workspace.root / name).mkdir(parents=True)
(self.workspace.root / 'labels').write_text('Ex 1\nEx 2\nEx 3\n')
(self.workspace.root / 'correction.json').write_text('{}')
self.heights = {'Ex 1': 60, 'Ex 2': 40, 'Ex 3': 60}
self.labels = {}
for label in self.heights:
pdf = self.workspace.root / 'Copies' / f'{label}.pdf'
pdf.touch()
self.labels[label] = {'pdf_path': pdf, 'result': {'score': 2, 'feedback': []},
'coordinates': (0, 0)}
self.output = self.workspace.root / 'Bnot' / 'Copie01'
def compose(self, base, label, result, coordinates, **kwargs):
image = Image.new('RGB', (100, self.heights[label]), 'white')
callback = kwargs.get('draw_callback')
if callback:
callback.__self__.checkboxes.append({
'type': 'score', 'label': label, 'value': 3,
'final_box': [10, 5, 30, 25],
})
ImageDraw.Draw(image).rectangle((10, 5, 30, 25), outline='black', width=2)
return image, 0
def generate(self, overwrite=False):
with (
patch.object(checks, 'MAX_HEIGHT_PX', 100),
patch.object(checks.annotating, 'make_base_image', return_value=(None, 0, 0)),
patch.object(checks.annotating, 'compose_label_image', side_effect=self.compose),
):
return checks._render_student(self.workspace, '01', self.labels,
overwrite=overwrite, output_mode='checks')
def test_splits_on_labels_and_resets_geometry_at_each_part(self):
self.assertEqual(MAX_HEIGHT_PX, 32000)
self.assertEqual(self.generate(), 'success')
parts = copy_documents(self.output)
self.assertEqual([part.name for part in parts], ['Part1', 'Part2'])
self.assertEqual([read_json(p / 'bnote.json')['height'] for p in parts], [100, 60])
self.assertEqual([item['hmin'] for item in read_json(self.output / 'bnote.json')['images']],
[0, 60, 0])
self.assertEqual(read_json(parts[1] / 'checkboxes.json')[0]['global_box'], [10, 5, 30, 25])
self.assertTrue(checks._output_complete(self.output))
self.assertEqual(self.generate(), 'skipped')
(parts[1] / 'Reference.jpg').unlink()
self.assertFalse(checks._output_complete(self.output))
def test_overwrite_removes_stale_parts_and_keeps_small_copy_layout(self):
self.generate()
self.labels = {'Ex 1': self.labels['Ex 1']}
self.generate(overwrite=True)
self.assertEqual(copy_documents(self.output), [self.output])
self.assertTrue((self.output / 'Concat.pdf').is_file())
self.assertFalse((self.output / 'Part1').exists())
def test_single_oversized_answer_and_save_failure_preserve_previous_copy(self):
self.generate()
sentinel = self.output / 'sentinel'
sentinel.write_text('previous')
self.heights['Ex 3'] = 101
with self.assertRaisesRegex(CliError, 'Copie01.*Ex 3'):
self.generate(overwrite=True)
self.assertEqual(sentinel.read_text(), 'previous')
self.heights['Ex 3'] = 60
original = checks._save_document
def fail_second(directory, *args):
if directory.name == 'Part2':
raise OSError('disk full')
original(directory, *args)
with patch.object(checks, '_save_document', side_effect=fail_second):
with self.assertRaisesRegex(OSError, 'disk full'):
self.generate(overwrite=True)
self.assertEqual(sentinel.read_text(), 'previous')
def returned_parts(self):
self.generate()
export_root = self.root / 'Export'
with patch.object(export, 'EXPORT_DIR', export_root):
self.assertEqual(export.run(self.workspace, annotation_dir='Bnot'), ExitCode.SUCCESS)
self.assertEqual(sorted(p.name for p in (export_root / 'Exam').iterdir()),
['Copie01 Part1.pdf', 'Copie01 Part2.pdf'])
import_root = self.root / 'Import'
import_root.mkdir()
for part in copy_documents(self.output):
with Image.open(part / 'Reference.jpg') as reference:
image = reference.convert('RGB')
draw = ImageDraw.Draw(image)
# Change Ex 3's score and add handwriting in its body.
if part.name == 'Part2':
draw.rectangle((14, 9, 26, 21), fill='black')
draw.rectangle((70, 40, 85, 50), fill='black')
image.save(import_root / f'Copie01 {part.name}.pdf', 'PDF', resolution=72)
self.assertEqual(import_annotations.sync_annotated(
self.workspace, annotation_dir_name='Bnot', import_dir=import_root), ExitCode.SUCCESS)
def test_export_import_and_reader_merge_parts_without_moving_notes(self):
self.returned_parts()
with (
patch.object(reader, 'load_annotation_data', return_value=AnnotationLoadResult({'01': self.labels}, [])),
patch.object(reader.annotating, 'make_base_image', return_value=(None, 0, 0)),
patch.object(reader.annotating, 'compose_label_image', side_effect=self.compose),
):
self.assertEqual(reader.run(self.workspace), ExitCode.SUCCESS)
self.assertEqual(read_json(self.output / 'score.json'), {'Ex 1': '2', 'Ex 2': '2', 'Ex 3': '3'})
with Image.open(self.output / 'Ex 3.jpg') as image:
self.assertLess(max(image.getpixel((77, 45))), 80)
with Image.open(self.output / 'Ex 1.jpg') as image:
self.assertGreater(min(image.getpixel((77, 45))), 230)
def test_missing_return_part_preserves_previous_regenerated_files(self):
self.returned_parts()
(self.output / 'Part2' / 'Concat_annotated.pdf').unlink()
(self.output / 'score.json').write_text('{"previous": true}')
with patch.object(reader, 'load_annotation_data', return_value=AnnotationLoadResult({'01': self.labels}, [])):
self.assertEqual(reader.run(self.workspace), ExitCode.PARTIAL)
self.assertEqual(read_json(self.output / 'score.json'), {'previous': True})
def test_redo_reader_accepts_split_copy_documents(self):
self.returned_parts()
# Redo uses the same document layout under BRnot.
self.output.parent.rename(self.workspace.root / 'BRnot')
actions, notes, incomplete = grouped_reader._scan_redo_annotations(
self.workspace.root / 'BRnot', {'01': set(self.labels)})
self.assertFalse(incomplete)
self.assertEqual(set(notes['01']), set(self.labels))
self.assertEqual(actions['01'][0]['label'], 'Ex 3')
(self.workspace.root / 'BRnot/Copie01/Part2/Concat_annotated.pdf').unlink()
_, _, incomplete = grouped_reader._scan_redo_annotations(
self.workspace.root / 'BRnot', {'01': set(self.labels)})
self.assertEqual(incomplete, {'01'})
if __name__ == '__main__':
unittest.main()
+44
View File
@@ -372,6 +372,50 @@ class GuiConvenienceTests(unittest.TestCase):
self.assertEqual(self.app.state_store.step("correction")["variant"], "integrate")
self.assertIn("--deal-with-batched", self.app._make_command())
def test_batch_integration_success_selects_conflicts_or_cleanup(self):
for conflicts, visited in product((None, "### Aucun conflit\n", "Copie01 A -> B\n"), (False, True)):
with self.subTest(conflicts=conflicts, visited=visited):
manual = self.evaluation / "manual_resolutions.txt"
if conflicts is None:
manual.unlink(missing_ok=True)
else:
manual.write_text(conflicts, encoding="utf-8")
(self.evaluation / "correction.json").write_text("{}", encoding="utf-8")
self.app.state_store.update_step(
"correction", variant="integrate", last_run_variant="integrate"
)
self.app.state_store.update_step("manual_resolution", visited=visited, status="stale")
self.app._populate_tree()
self.app.tree.selection_set("correction")
self.app.update()
self.app.active_step_id = "correction"
with patch.object(self.app.runner, "start") as start:
self.app._finish_process(0, False)
self.app.update()
start.assert_not_called()
expected = "manual_resolution" if conflicts == "Copie01 A -> B\n" else "post_correction"
self.assertEqual(self.app.current_step.id, expected)
expected_status = "stale" if expected == "manual_resolution" else "success"
self.assertEqual(self.app.state_store.step("manual_resolution")["status"], expected_status)
if expected_status == "success":
self.assertEqual(self.app.tree.item("manual_resolution", "values"), ("Réussie",))
for step_id in ("submit_batches", "batch_status", "fetch_batches", "prepare_recorrection"):
self.assertFalse(self.app.state_store.step(step_id).get("visited"))
def test_unsuccessful_batch_integration_stays_on_correction(self):
self.app.state_store.update_step(
"correction", variant="integrate", last_run_variant="integrate"
)
self.app._populate_tree()
self.app.tree.selection_set("correction")
self.app.update()
for code, interrupted in ((4, False), (1, False), (0, True)):
with self.subTest(code=code, interrupted=interrupted):
self.app.active_step_id = "correction"
self.app._finish_process(code, interrupted)
self.app.update()
self.assertEqual(self.app.current_step.id, "correction")
def test_correction_invalidates_batches_only_when_regenerating_or_resetting(self):
for variant, action in product(
("integrate", "live", "refaire", "batch", "hybrid", "reset"), ("run", "mark")
+22 -6
View File
@@ -2338,12 +2338,28 @@ class StandardCliTests(unittest.TestCase):
("01", "Ex 2", image, 0, []),
("02", "Ex 1", image, 0, []),
]
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))
with patch.object(module, "MAX_HEIGHT_PX", 100):
batches = module.split_batches(rendered)
self.assertEqual(batches, [rendered[:2], rendered[2:]])
def test_grouped_oversized_answer_is_saved_as_its_own_group(self) -> None:
module = self.modules["annotating_by_label"]
rendered = [
("01", "Ex 1", Image.new("RGB", (10, 40)), 0, []),
("02", "Ex 1", Image.new("RGB", (10, 120)), 0, []),
("03", "Ex 1", Image.new("RGB", (10, 50)), 0, []),
]
with tempfile.TemporaryDirectory() as directory, patch.object(module, "MAX_HEIGHT_PX", 100):
batches = module.split_batches(rendered)
self.assertEqual(batches, [[rendered[0], rendered[2]], [rendered[1]]])
for index, batch in enumerate(batches, start=1):
module.save_batch(batch, "Ex 1", index, Path(directory))
metadata = read_json(Path(directory) / "Ex 1 G2/bnote.json")
self.assertEqual(metadata["height"], 120)
self.assertEqual([item["id"] for item in metadata["images"]], ["02"])
self.assertTrue((Path(directory) / "Ex 1 G2/Concat.pdf").is_file())
with self.assertRaises(CliError):
module.save_batch(rendered[:2], "Ex 1", 3, Path(directory))
def test_grouped_batching_reuses_space_in_earlier_groups(self) -> None:
module = self.modules["annotating_by_label"]