Initial support for user general remarks

This commit is contained in:
sebastien committed 2026-10-28 13:51:59 +01:00
1 parent a38c1ff4b3
commit 2c9dc65640
16 files changed
+606 -20

No files matched your search

+15
View File
@@ -76,6 +76,21 @@ class ModificationTests(unittest.TestCase):
self.assertTrue(all(abs(a-b) < 12 for a, b in zip(image.getpixel(xy), expected)),
(image.getpixel(xy), expected))
def test_general_remarks_survive_repeated_body_edits(self):
from copienator.general_remarks import IMAGE_FILENAME
for mode in ("Bnot", "BGnot"):
with self.subTest(mode=mode):
self.mode = mode
self.seed()
image = Image.new("RGB", (120, 60), "magenta")
image.save(self.output / IMAGE_FILENAME)
original = (self.output / IMAGE_FILENAME).read_bytes()
for _ in range(2):
self.apply("Copie43 Ex 1 c{40}2> |Ex 2")
self.assertEqual((self.output / IMAGE_FILENAME).read_bytes(), original)
self.assert_color(self.image("Concat"), (50, 30), "magenta")
self.assertTrue((self.output / "Concat_F.pdf").is_file())
def test_cut_uses_body_height_preserves_own_headers_and_scores(self):
self.apply("Copie43 Ex 1 c{40}2> |Ex 2")
source, target = self.image("Ex 1"), self.image("Ex 2")
+80
View File
@@ -0,0 +1,80 @@
from copy import deepcopy
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from PIL import Image
from pdf2image import convert_from_path
from copienator import EvaluationWorkspace, ExitCode, atomic_write_json
from copienator import general_remarks as remarks
from copienator.commands import annotating, reading_annotations as standard
from copienator.commands import reading_grouped_annotations as grouped
from copienator.return_answers import publish_answer_returns
class GeneralRemarkTests(unittest.TestCase):
def test_order_normalization_and_deletion_preserve_other_copies(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
remarks.set_remarks(root, "01", [" First ", "Second"])
remarks.set_remarks(root, "2", ["Other"])
self.assertEqual(remarks.set_remarks(root, "1", ["Second", "First"]), ["First", "Second"])
self.assertEqual(remarks.remarks_for_copy(root, "01"), ["Second", "First"])
remarks.set_remarks(root, "1", [])
self.assertEqual(remarks.load_remarks(root), {"2": ["Other"]})
def test_latex_has_one_paragraph_per_remark_without_truncation(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
remarks.set_remarks(root, "1", [r"Justifier $x^2$.", "Soigner la rédaction."])
with patch.object(annotating, "render_real_latex_text", return_value=Image.new("RGB", (600, 150), "white")) as render:
image = remarks.render(root, "01", 800)
self.assertEqual(image.size, (800, 190))
self.assertIn(r"Justifier $x^2$.\par\medskip Soigner", render.call_args.args[0])
self.assertNotIn("max_lines", render.call_args.kwargs)
def test_readers_publish_header_even_for_perfect_answers_then_remove_it(self):
for mode in ("Bnot", "BGnot"):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
workspace = EvaluationWorkspace(root)
output = root / mode / "Copie01"
output.mkdir(parents=True)
(root / "labels").write_text("Ex 1\n")
answer = root / "answer.pdf"
answer.touch()
atomic_write_json(output / "bnote.json", {"images": [{"label": "Ex 1"}]})
atomic_write_json(output / "score.json", {"Ex 1": "4"})
data = {"01": {"Ex 1": {"pdf_path": answer, "coordinates": (0, 0),
"result": {"score": 4, "feedback": []}}}}
remarks.set_remarks(root, "1", ["First", "Second"])
def run():
if mode == "Bnot":
return standard.apply_actions_and_regenerate(workspace, deepcopy(data), "01", [], None, ["Ex 1"], update_score=True)
return grouped.apply_actions_and_regenerate_grouped(workspace, deepcopy(data), "01", [], {}, ["Ex 1"], update_score=True)[0]
with patch.object(annotating, "make_base_image", return_value=(Image.new("RGB", (800, 100), "blue"), 0, 0)), \
patch.object(annotating, "compose_label_image", return_value=(Image.new("RGB", (800, 100), "blue"), 10)), \
patch.object(annotating, "render_real_latex_text", return_value=Image.new("RGB", (760, 60), "red")), \
patch.object(grouped, "get_extra_pdfs_as_images", return_value=[]), \
patch("copienator.configuration.RETURN_ANSWERS_ENABLED", True), \
patch("copienator.return_answers.load_return_answer_options", return_value={"context": False, "question": False, "solution": False}):
self.assertEqual(run(), ExitCode.SUCCESS)
with Image.open(output / "Concat.jpg") as image:
self.assertEqual(image.height, 200)
self.assertGreater(image.getpixel((40, 40))[0], 230)
self.assertGreater(image.getpixel((40, 150))[2], 230)
pages = convert_from_path(output / "Concat_F.pdf", dpi=100)
self.assertGreater(pages[0].getpixel((60, 60))[0], 230)
destination = root / "A Rendre" / "Student (01)"
publish_answer_returns(root, output, destination)
self.assertTrue((destination / "answers" / remarks.IMAGE_FILENAME).is_file())
remarks.set_remarks(root, "1", [])
self.assertEqual(run(), ExitCode.SUCCESS)
publish_answer_returns(root, output, destination)
self.assertFalse((output / remarks.IMAGE_FILENAME).exists())
self.assertFalse((output / "Concat_F.pdf").exists())
self.assertFalse((destination / "answers" / remarks.IMAGE_FILENAME).exists())
with Image.open(output / "Concat.jpg") as image:
self.assertEqual(image.height, 100)
+29
View File
@@ -132,6 +132,32 @@ class ScoreEditorGuiTests(ScoreEditorTests):
self.panel.set_busy(True)
self.assertEqual(str(self.panel.remark_button.cget("state")), "disabled")
def test_general_remarks_edits_invalidate_generation_and_preserve_options(self):
from copienator.general_remarks import remarks_for_copy as general_for_copy
self.app.tree.selection_set("general_remarks")
self.app.update()
panel = self.app.general_remarks_panel
panel.id_var.set("01")
panel.editor.insert("1.0", "First")
self.app.state_store.update_step("read_annotations", status="success",
values={"update_score": False, "return_answers_solution": False})
panel.save()
entry = self.app.state_store.step("read_annotations")
self.assertEqual(entry["status"], "stale")
self.assertTrue(entry["values"]["update_score"])
self.assertFalse(entry["values"]["return_answers_solution"])
panel.new()
panel.editor.insert("1.0", "Second")
panel.save()
panel.move(-1)
self.assertEqual(general_for_copy(self.evaluation, "1"), ["Second", "First"])
panel.delete()
panel.listbox.selection_set(0)
panel.delete()
self.assertEqual(general_for_copy(self.evaluation, "1"), [])
panel.id_var.set("2")
self.assertEqual(str(panel.editor.cget("state")), "disabled")
def test_completing_editor_selects_regeneration_and_preserves_options(self):
self.app.state_store.update_step("read_annotations", status="success", variant="grouped",
values={"update_score": False, "return_answers_solution": False, "annotation_dir": "BGnot"})
@@ -140,6 +166,9 @@ class ScoreEditorGuiTests(ScoreEditorTests):
self.app.update()
start.assert_not_called()
self.assertEqual(self.app.state_store.step("edit_scores")["status"], "success")
self.assertEqual(self.app.current_step.id, "general_remarks")
self.app.run_button.invoke()
self.app.update()
self.assertEqual(self.app.current_step.id, "read_annotations")
self.assertEqual(self.app.tree.selection(), ("read_annotations",))
self.assertEqual(self.app.tree.item("read_annotations", "values"), ("À revalider",))
@@ -12,6 +12,7 @@ from copienator import EvaluationWorkspace, ExitCode, atomic_write_json, configu
from copienator.annotation_data import AnnotationLoadResult
from copienator.annotation_read_cache import STATE_FILE
from copienator.custom_remarks import set_remark
from copienator.general_remarks import set_remarks, IMAGE_FILENAME
from copienator.commands import annotating, reading_annotations as standard
from copienator.commands import reading_grouped_annotations as grouped
@@ -103,6 +104,19 @@ class IncrementalAnnotationReadsTests(unittest.TestCase):
self.assertEqual(compose.call_args.args[2]["feedback"], [])
run(0)
with patch.object(annotating, "render_real_latex_text",
return_value=Image.new("RGB", (100, 30), "white")):
set_remarks(root, "1", ["First", "Second"])
run(1)
run(0)
set_remarks(root, "1", ["Second", "First"])
run(1)
(root / mode / "Copie01" / IMAGE_FILENAME).unlink()
run(1)
set_remarks(root, "1", [])
run(1)
run(0)
# Per-copy sources and correction data invalidate only that copy.
(root / "Copies" / "01.pdf").write_bytes(b"replaced answer PDF")
run(1)
+75
View File
@@ -118,6 +118,81 @@ class LeftMarginTests(unittest.TestCase):
mask[300:960] = False
self.assertIsNone(left_margin.body_edge(mask)['edge_mm'])
def test_bands_starting_beyond_midpoint_are_empty_for_both_windows(self):
mask = np.zeros((1169, 827), bool)
for y in range(370, 1050, 35):
mask[y:y + 5, 450:550] = True
result = left_margin.body_edge(mask)
self.assertIsNone(result['edge_mm'])
self.assertEqual(result['band_starts_mm'], [])
self.assertEqual(result['label_band_starts_mm'], [])
mask[400:410, 100:200] = True
self.assertTrue(left_margin.body_edge(mask)['band_starts_mm'])
def test_label_window_keeps_lower_body_but_excludes_names_and_bottom_eight_percent(self):
mask = np.zeros((1169, 827), bool)
mask[100:300, 5:80] = True # Name/header; exclude from both pools.
mask[1100:1150, 5:80] = True # Footer; below 92%.
for y in range(370, 920, 35):
mask[y:y + 5, 170:270] = True
mask[1000:1010, 40:140] = True # Label at 85.5%; new window only.
result = left_margin.body_edge(mask)
self.assertGreater(min(result['band_starts_mm']), 40)
self.assertAlmostEqual(min(result['label_band_starts_mm']), 10.16)
def test_fifth_percentile_detects_labels_while_page_decile_stays_at_body(self):
images = [Image.new('RGB', (1654, 200)) for _ in range(6)]
bands = [5.] * 7 + [42.] * 93
estimate = {'edge_mm': left_margin.trimmed_left_edge(bands),
'bands': len(bands), 'band_starts_mm': bands,
'label_band_starts_mm': bands}
with patch.object(left_margin, 'estimate_page', return_value=estimate):
result = left_margin.analyse_margins(images, 200, 1654)
group = result['groups'][0]
self.assertEqual(group['edge_mm'], 42)
self.assertEqual(group['body_left_mm'], 22)
self.assertEqual(group['label_edge_mm'], 5)
self.assertTrue(group['labels_in_margin'])
self.assertEqual(result['starts_px'], [0] * 6)
def test_unsampled_coherent_page_can_move_the_whole_group_left(self):
images = [Image.new('RGB', (1654, 200)) for _ in range(6)]
def estimate(image, _dpi):
labels = [5.] * 20 if image is images[2] else []
return {'edge_mm': 42, 'bands': 12, 'band_starts_mm': [42.] * 12,
'label_band_starts_mm': labels + [42.] * 12}
with patch.object(left_margin, 'estimate_page', side_effect=estimate):
result = left_margin.analyse_margins(images, 200, 1654)
group = result['groups'][0]
self.assertNotIn(3, [p['page'] for p in group['samples']])
self.assertIn(3, group['coherent_pages'])
self.assertEqual(result['starts_px'], [0] * 6)
def test_sparse_or_incoherent_pages_do_not_supply_label_evidence(self):
images = [Image.new('RGB', (1654, 200)) for _ in range(6)]
edges = [42, 42, None, 42, 65, 42]
def estimate(image, _dpi):
edge = edges[next(i for i, candidate in enumerate(images) if candidate is image)]
return {'edge_mm': edge, 'bands': 12, 'band_starts_mm': [edge or 42.] * 12,
'label_band_starts_mm': [1.] * 20 if edge in (None, 65) else [42.] * 12}
with patch.object(left_margin, 'estimate_page', side_effect=estimate):
result = left_margin.analyse_margins(images, 200, 1654)
group = result['groups'][0]
self.assertEqual(group['coherent_pages'], [1, 2, 4, 6])
self.assertFalse(group['labels_in_margin'])
self.assertGreater(result['starts_px'][0], 0)
def test_less_than_five_percent_noise_or_small_gap_does_not_trigger_labels(self):
for bands in ([1.] * 4 + [42.] * 96, [35.] * 7 + [42.] * 93):
with self.subTest(bands=bands[:8]):
images = [Image.new('RGB', (1654, 200)) for _ in range(2)]
estimate = {'edge_mm': 42, 'bands': 100, 'band_starts_mm': [42.] * 100,
'label_band_starts_mm': bands}
with patch.object(left_margin, 'estimate_page', return_value=estimate):
result = left_margin.analyse_margins(images, 200, 1654)
self.assertFalse(result['groups'][0]['labels_in_margin'])
self.assertGreater(result['starts_px'][0], 0)
def test_real_mask_rejects_ruling_and_preserves_handwriting(self):
image = Image.new('RGB', (827, 1169), 'white')
draw = ImageDraw.Draw(image)
+55
View File
@@ -0,0 +1,55 @@
"""Check benchmark scoring and independent fixture annotations (no PDF render)."""
import hashlib
import importlib.util
import json
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location('check_margins', ROOT / 'cropping-test/check_margins.py')
benchmark = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(benchmark)
class MarginBenchmarkTests(unittest.TestCase):
def test_label_must_fit_both_strip_edges_even_when_start_is_acceptable(self):
expected = {'start_mm_range': [0, 20], 'labels': [
{'box_px': [20, 100, 50, 140], 'label': 'left heading'},
{'box_px': [590, 200, 630, 240], 'label': 'right heading'},
]}
result = benchmark.score_page({'left_px': 30, 'right_px': 620}, expected)
self.assertTrue(result['start_in_range'])
self.assertFalse(result['pass'])
self.assertEqual(len(result['missed_labels']), 2)
self.assertTrue(benchmark.score_page({'left_px': 20, 'right_px': 630}, expected)['pass'])
def test_unannotated_page_still_checks_reviewed_start_range(self):
expected = {'start_mm_range': [0, 3], 'labels': []}
self.assertFalse(benchmark.score_page({'left_px': 116, 'right_px': 722}, expected)['pass'])
self.assertTrue(benchmark.score_page({'left_px': 0, 'right_px': 606}, expected)['pass'])
def test_fixture_provenance_whole_copy_order_and_suggested_strip_containment(self):
manifest = json.loads((benchmark.ROOT / 'manifest.json').read_text())
self.assertEqual(manifest['render_dpi'], 200)
self.assertEqual({c['id'] for c in manifest['cases']},
{f'DS02VA-Copie{n:02}' for n in (1, 2, 3, 5, 8, 10, 16, 24, 25, 32)})
for case in manifest['cases']:
with self.subTest(case=case['id']):
self.assertEqual(hashlib.sha256((benchmark.ROOT / case['source']).read_bytes()).hexdigest(),
case['sha256'])
self.assertEqual([p['page'] for p in case['pages']], list(range(1, case['page_count'] + 1)))
self.assertTrue(case['explanation'])
self.assertTrue(any(p['labels'] for p in case['pages']))
for expected, recorded in zip(case['pages'], case['recorded_schema']['page_crops'], strict=True):
start = round(expected['suggested_start_mm'] * 200 / 25.4)
crop = {'left_px': start, 'right_px': min(recorded['width_px'],
start + recorded['right_px'] - recorded['left_px'])}
self.assertTrue(benchmark.score_page(crop, expected)['pass'])
for label in expected['labels']:
x0, y0, x1, y1 = label['box_px']
self.assertTrue(0 <= x0 < x1 <= recorded['width_px'])
self.assertTrue(0 <= y0 < y1 <= recorded['height_px'])
if __name__ == '__main__':
unittest.main()