This commit is contained in:
sebastien committed 2026-10-21 22:28:27 +02:00
1 parent 0d8970061e
commit c30aac0572
14 files changed
+343 -48

No files matched your search

+77
View File
@@ -208,6 +208,28 @@ class DispatcherTests(unittest.TestCase):
class AtomicJsonTests(unittest.TestCase):
def test_label_json_repairs_only_trailing_commas_and_preserves_strings(self) -> None:
text = '{"list": [{"label": "comma,] and ,} and \\\"quoted\\\"",},],}\n'
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "Copie01_01.json"
path.write_text(text)
with self.assertRaises(json.JSONDecodeError):
read_json(path)
value = read_json(path, repair_trailing_commas=True)
self.assertEqual(value, {"list": [{"label": 'comma,] and ,} and "quoted"'}]})
self.assertEqual(json.loads(path.read_text()), value)
self.assertEqual(path.read_text(), text.replace('",}', '"}').replace('},]', '}]').replace('],}', ']}'))
def test_trailing_comma_repair_preserves_other_invalid_json(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "Copie01_01.json"
for text in ('[,]', '{,}', '[1,,]', '{"list": [1,], "bad": }', '[1,] garbage'):
with self.subTest(text=text):
path.write_text(text)
with self.assertRaises(json.JSONDecodeError):
read_json(path, repair_trailing_commas=True)
self.assertEqual(path.read_text(), text)
def test_atomic_binary_round_trip(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "result.jsonl"
@@ -969,6 +991,27 @@ class StandardCliTests(unittest.TestCase):
[original],
)
def test_page_splitter_excludes_statement_aliases_from_copy_inputs(self) -> None:
module = self.modules["page_splitter"]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "DS02VA"
copies = root / "Copies"
copies.mkdir(parents=True)
workspace = EvaluationWorkspace(root)
# Recognize a spaced alias even after it has been moved out of root.
for name in ("enonce.pdf", "énoncé.pdf", "DS02 VA.pdf"):
(copies / name).touch()
copy = copies / "Copie01.pdf"
copy.touch()
self.assertEqual(module._selected_inputs(workspace, copies), [copy])
statement = root / "DS02 VA.pdf"
statement.touch()
copy = root / "Copie01.pdf"
copy.touch()
self.assertEqual(module._selected_inputs(workspace, root), [copy])
with self.assertRaises(CliError):
module._selected_inputs(workspace, statement)
def test_plotting_batch_save_is_atomic(self) -> None:
module = self.modules["plotting"]
with tempfile.TemporaryDirectory() as directory:
@@ -1105,6 +1148,40 @@ class StandardCliTests(unittest.TestCase):
previous_image, previous_json, previous_meta
)
def test_plotting_edit_prepares_only_the_last_label_comma(self) -> None:
module = self.modules["plotting"]
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "Copie01_01.json"
text = '{"name": "literal ] and ,}", "list": [{"box_2d": [1,2,3,4], "label": "Ex 1"}], "other": [1,2]}\n'
path.write_text(text)
viewer = module.ImageViewer.__new__(module.ImageViewer)
viewer.is_viewing = True
viewer.current_json_path = path
with patch.object(module, "open_path") as opened:
viewer.on_edit(None)
opened.assert_called_once_with(path)
expected = text.replace('"Ex 1"}]', '"Ex 1"},]')
self.assertEqual(path.read_text(), expected)
module.prepare_label_json_for_edit(path)
self.assertEqual(path.read_text(), expected)
# Pasting a new item works without adding a separator first.
path.write_text(expected.replace('},]', '}, {"label": "Ex 2"},]'))
self.assertEqual([item['label'] for item in read_json(path, repair_trailing_commas=True)['list']], ['Ex 1', 'Ex 2'])
def test_plotting_edit_preserves_empty_lists_and_other_json_errors(self) -> None:
module = self.modules["plotting"]
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "Copie01_01.json"
for text in ('{"list": []}', '{"list": [oops]}'):
path.write_text(text)
viewer = module.ImageViewer.__new__(module.ImageViewer)
viewer.is_viewing = True
viewer.current_json_path = path
with patch.object(module, "open_path") as opened:
viewer.on_edit(None)
opened.assert_called_once_with(path)
self.assertEqual(path.read_text(), text)
def test_plotting_accepts_lowercase_and_uppercase_previous_shortcuts(self) -> None:
module = self.modules["plotting"]
root = Mock()
+39 -9
View File
@@ -1,6 +1,6 @@
import unittest
from pathlib import Path
from unittest.mock import patch
from unittest.mock import Mock, patch
import numpy as np
from PIL import Image, ImageDraw, ImageFont
@@ -10,6 +10,34 @@ from copienator.commands import cutleft
class LeftMarginTests(unittest.TestCase):
def test_review_previous_restores_settings_and_respects_navigation_guards(self):
reviewer = cutleft.ImageReviewer.__new__(cutleft.ImageReviewer)
reviewer.files = [Path('Copie01.pdf'), Path('Copie02.pdf')]
reviewer.index = 0
reviewer.is_processing = False
reviewer.current_shift = -50
reviewer.current_width_offset = 100
reviewer.current_max_per_file = 1
reviewer.default_max_per_file = 5
reviewer.review_settings = {}
reviewer.trigger_processing = Mock()
reviewer.on_previous()
reviewer.trigger_processing.assert_not_called()
reviewer._advance()
self.assertEqual(reviewer.index, 1)
self.assertEqual((reviewer.current_shift, reviewer.current_width_offset, reviewer.current_max_per_file), (0, 0, 5))
reviewer.current_shift = 50
reviewer.is_processing = True
reviewer.on_previous()
self.assertEqual(reviewer.index, 1)
reviewer.is_processing = False
reviewer.on_previous()
self.assertEqual(reviewer.index, 0)
self.assertEqual((reviewer.current_shift, reviewer.current_width_offset, reviewer.current_max_per_file), (-50, 100, 1))
reviewer.trigger_processing.assert_called_with(Path('Copie01.pdf'), -50)
reviewer._advance()
self.assertEqual(reviewer.current_shift, 50)
def test_trim_discards_exactly_ten_percent_without_interpolation(self):
self.assertEqual(left_margin.trimmed_left_edge([100, 1, 9, 8, 7, 6, 5, 4, 3, 2]), 2)
self.assertEqual(left_margin.trimmed_left_edge([5] * 9), 5)
@@ -24,14 +52,14 @@ class LeftMarginTests(unittest.TestCase):
result = left_margin.analyse_margins(images, 200, 1654)
group = result['groups'][0]
self.assertEqual(group['edge_mm'], 42)
self.assertEqual(group['left_mm'], 21)
self.assertEqual(group['left_mm'], 22)
self.assertEqual(group['discarded_bands'], 14)
self.assertEqual(result['starts_px'], [round(21 * 200 / 25.4)] * 4)
self.assertEqual(result['starts_px'], [round(22 * 200 / 25.4)] * 4)
def test_repeated_left_writing_beyond_ten_percent_is_retained(self):
self.assertEqual(left_margin.trimmed_left_edge([10] * 20 + [42] * 80), 10)
def test_disagreeing_pages_use_half_the_trimmed_margin(self):
def test_disagreeing_pages_use_half_margin_when_it_removes_more(self):
result = self.analyse([25, 31, 68, 49])
self.assertEqual(result['groups'][0]['left_mm'], 12.5)
self.assertEqual(result['starts_px'][1:], [round(12.5 * 200 / 25.4)] * 3)
@@ -52,9 +80,9 @@ class LeftMarginTests(unittest.TestCase):
return {'edge_mm': edge, 'bands': bands, 'band_starts_mm': [edge] * bands}
with patch.object(left_margin, 'estimate_page', side_effect=estimate):
result = left_margin.analyse_margins(images, 200, 1654)
self.assertEqual(result['groups'][0]['left_mm'], 21)
self.assertEqual(result['groups'][0]['left_mm'], 22)
self.assertEqual(result['no_margin_from_page'], 3)
self.assertEqual(result['starts_px'], [round(21 * 200 / 25.4)] * 2 + [0] * 6)
self.assertEqual(result['starts_px'], [round(22 * 200 / 25.4)] * 2 + [0] * 6)
def test_isolated_low_or_blank_pages_do_not_trigger_no_margin_rule(self):
for edges in ([42, 10, 42, 12], [42, 10, None, 12]):
@@ -70,11 +98,13 @@ class LeftMarginTests(unittest.TestCase):
self.assertGreater(result['starts_px'][0], 0)
self.assertEqual(result['starts_px'][2:], [0] * 4)
def test_half_standard_margin_threshold(self):
for edge, expected in ((19.9, 0), (20, 10), (20.1, 10.05), (40, 20)):
def test_larger_cut_distance_and_no_margin_threshold(self):
for edge, expected in ((0, 0), (19.9, 0), (20, 10), (20.1, 10.05), (28.4, 14.2), (40, 20), (60, 40)):
with self.subTest(edge=edge):
result = self.analyse([edge] * 4)
self.assertEqual(result['starts_px'][1:], [round(expected * 200 / 25.4)] * 3)
self.assertEqual(result['groups'][0]['status'], 'near-edge' if edge < 20 else 'margin')
self.assertEqual(result['margin_clearance_mm'], 20)
def test_body_bands_ignore_header_footer_and_abstain_on_sparse_ink(self):
mask = np.zeros((1169, 827), bool)
@@ -119,7 +149,7 @@ class LeftMarginTests(unittest.TestCase):
def test_clear_margin_uses_same_conservative_edge_on_every_page(self):
result = self.analyse([43, 41, 46, 44])
self.assertEqual(result['groups'][0]['status'], 'margin')
self.assertEqual(result['starts_px'], [round(20.5 * 200 / 25.4)] * 4)
self.assertEqual(result['starts_px'], [round(21 * 200 / 25.4)] * 4)
self.assertNotIn('first_page_body_start_px', result)
def test_sparse_groups_stay_at_left(self):
+40
View File
@@ -16,6 +16,36 @@ from copienator_gui.workflow import build_workflow
class PersonalStatementTests(unittest.TestCase):
def test_sheetinfo_numbering_controls_labels_and_summary(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
blocks = [
{"id": 42, "numbering": "problem", "indexes": [{"indices": [1, 3]}]},
{"id": 99, "numbering": "problem_abc", "indexes": [
{"indices": [1, 3]}, {"indices": [1, 1, 1]}]},
{"id": 100, "numbering": "exercise", "indexes": [{"indices": [1, 2]}]},
]
# Explicit metadata takes precedence over the old Roman heuristic.
(root / "enonce.tex").write_text("\n".join(
"%%SHEETINFO : " + json.dumps(block) + "\n\\Roman" for block in blocks))
def fetch(url):
if "/emacs/" in url:
return io.BytesIO(b" 1. Section\n c. Part\n 1. Question\n"
b"###Solution\n c. Answer\n###Rubric\n c. Points")
return io.BytesIO(b"Selected question")
with patch.object(personal.urllib.request, "urlopen", side_effect=fetch), \
patch.object(personal, "compile_to_pdf"):
self.assertEqual(personal.process_directory(EvaluationWorkspace(root)), ExitCode.SUCCESS)
expected = ["Ex 1 : I)3)", "Ex 2 : I)C)", "Ex 2 : I)A)1)", "Ex 3 : 1)b)"]
self.assertEqual((root / "labels").read_text().splitlines(), expected)
for label in expected:
for folder in ("Text2", "Sol2"):
self.assertTrue((root / folder / (label + ".tex")).is_file())
for folder in ("Text", "Sol", "Persp"):
self.assertIn(" C)", (root / folder / "Ex 2").read_text())
def test_personal_generation_uses_statement_and_groups_by_exercise(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -95,6 +125,16 @@ class SelectiveGeminiTests(unittest.TestCase):
before.pop("label_groups")
self.assertEqual(after, before)
def test_grouping_follows_label_order_instead_of_gemini_order(self):
self.client.models.generate_content.return_value = self.response({
"groups": [[self.labels[2]], [self.labels[1], self.labels[0]]],
})
self.assertEqual(gemini.refine_existing(self.workspace, "groups", api_client=self.client), ExitCode.SUCCESS)
self.assertEqual(
self.workspace.label_groups_file.read_text(),
", ".join(self.labels[:2]) + "\n" + self.labels[2] + "\n",
)
def test_invalid_grouping_preserves_existing_groups(self):
before = self.snapshot()
for groups in ([[self.labels[0]]], [[*self.labels, self.labels[0]]], [[*self.labels, "invented"]]):