améliorations diverses

This commit is contained in:
2026-09-17 22:19:05 +02:00
parent 5b8215e7f5
commit 0a453403cf
11 changed files with 1072 additions and 81 deletions
+448
View File
@@ -43,6 +43,11 @@ from copienator_gui.app import (
plotting_shortcut_lines,
process_status,
)
from copienator_gui.giving_names import (
find_name_issues,
original_copy_path,
rename_return_copy,
)
from copienator_gui.diagnostics import collect_diagnostics
from copienator_gui.runner import ProcessRunner
from copienator_gui.state import StateStore
@@ -911,6 +916,43 @@ class StandardCliTests(unittest.TestCase):
)
self.assertIsNone(viewer.accumulated_results)
def test_plotting_detects_labels_missing_from_two_thirds_of_copies(self) -> None:
module = self.modules["plotting"]
with tempfile.TemporaryDirectory() as directory:
copies = Path(directory)
detected = {
"Copie01_01.json": ["Ex 1", "Ex 3"],
"Copie02_01.json": ["Ex 1", "Ex 3"],
"Copie03_01.json": ["Ex 1", "Ex 2", "Ex 3"],
}
for filename, labels in detected.items():
atomic_write_json(
copies / filename,
{"list": [{"label": label} for label in labels]},
)
missing = module.frequently_missing_labels(
copies,
["Ex 1", "Ex 2", "Ex 3", "Ex 4"],
)
self.assertEqual(missing, {"Ex 2", "Ex 4"})
def test_plotting_mutes_only_a_single_commonly_missing_label(self) -> None:
module = self.modules["plotting"]
labels = ["Ex 1", "Ex 2", "Ex 3", "Ex 4"]
muted, index = module.label_color("Ex 3", labels, 0, {"Ex 2"})
ordinary, _ = module.label_color("Ex 3", labels, 0, set())
two_missing, _ = module.label_color(
"Ex 4", labels, 0, {"Ex 2", "Ex 3"}
)
self.assertEqual(muted, module.COMMON_MISSING_LABEL_COLOR)
self.assertEqual(index, 2)
self.assertEqual(ordinary, module.MISSING_LABEL_COLOR)
self.assertEqual(two_missing, module.MISSING_LABEL_COLOR)
def test_plotting_validation_preserves_manually_edited_order(self) -> None:
module = self.modules["plotting"]
with tempfile.TemporaryDirectory() as directory:
@@ -931,6 +973,7 @@ class StandardCliTests(unittest.TestCase):
"part": 1,
}
viewer.valid_labels = {"Ex 1", "Ex 2"}
viewer.active_copie_name = "Copie01"
viewer.accumulated_results = {"name": "Student", "list": []}
viewer.history = []
viewer.current_pil_image = Mock()
@@ -944,6 +987,49 @@ class StandardCliTests(unittest.TestCase):
["Ex 2", "Ex 1"],
)
def test_plotting_previous_restores_state_across_copies(self) -> None:
module = self.modules["plotting"]
viewer = module.ImageViewer.__new__(module.ImageViewer)
previous_image = Mock()
previous_json = Path("Copie01_02.json")
previous_meta = {"copie": "Copie01"}
previous_results = {
"name": "Student 1",
"list": [{"label": "Ex 1"}],
}
current_image = Mock()
current_json = Path("Copie02_01.json")
current_meta = {"copie": "Copie02"}
viewer.is_viewing = True
viewer.history = [
(
previous_image,
previous_json,
previous_meta,
"Copie01",
previous_results,
)
]
viewer.forward_stack = []
viewer.current_pil_image = current_image
viewer.current_json_path = current_json
viewer.current_meta = current_meta
viewer.active_copie_name = "Copie02"
viewer.accumulated_results = {"name": "Student 2", "list": []}
viewer.display_image = Mock()
viewer.on_previous(None)
self.assertEqual(viewer.active_copie_name, "Copie01")
self.assertEqual(viewer.accumulated_results, previous_results)
self.assertEqual(
viewer.forward_stack,
[(current_image, current_json, current_meta)],
)
viewer.display_image.assert_called_once_with(
previous_image, previous_json, previous_meta
)
def test_plotting_validates_plain_and_directional_labels(self) -> None:
module = self.modules["plotting"]
self.assertEqual(
@@ -1226,6 +1312,27 @@ class StandardCliTests(unittest.TestCase):
self.assertEqual(module.results, {"Ex 1": []})
self.assertEqual(module.tasks_to_process, [("new.jpg", "Ex 1")])
def test_correction_rejects_overwrite_with_refaire_without_writing(self) -> None:
module = self.modules["correction"]
with tempfile.TemporaryDirectory() as directory:
evaluation = Path(directory) / "Exam"
evaluation.mkdir()
correction = evaluation / "correction.json"
original = {"Ex 1": [[{"id": "01", "result": {"feedback": []}}]]}
atomic_write_json(correction, original)
errors = io.StringIO()
with redirect_stderr(errors):
status = module.main(
[str(evaluation), "--refaire", "--overwrite"]
)
self.assertEqual(status, module.ExitCode.INVALID_ARGUMENTS)
self.assertIn(
"--overwrite cannot be used with --refaire", errors.getvalue()
)
self.assertEqual(read_json(correction), original)
def test_correction_reserves_unique_group_indices_concurrently(self) -> None:
module = self.modules["correction"]
with tempfile.TemporaryDirectory() as directory:
@@ -1247,6 +1354,251 @@ class StandardCliTests(unittest.TestCase):
self.assertEqual(sorted(indices), list(range(3, 11)))
self.assertEqual(len(indices), len(set(indices)))
def test_live_correction_reports_progress_for_generated_groups(self) -> None:
module = self.modules["correction"]
with tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory))
args = module.build_parser().parse_args(
[str(workspace.root), "--overwrite"]
)
initial_tasks = [("group-1.jpg", "Ex 1"), ("group-2.jpg", "Ex 2")]
module.configure_runtime(
workspace,
initial_tasks,
args,
api_client=Mock(),
)
processed = []
def process(task, _precomputed=None):
processed.append(task[0])
if task[0] == "group-1.jpg":
return [("group-3.jpg", "Ex 3", False)]
return []
output = io.StringIO()
with patch.object(
module, "process_single_task", side_effect=process
), patch.object(
module, "resolve_delayed_moves", return_value=[]
), redirect_stdout(output):
self.assertEqual(module.run_configured(args), 0)
self.assertCountEqual(
processed,
["group-1.jpg", "group-2.jpg", "group-3.jpg"],
)
progress = [
line
for line in output.getvalue().splitlines()
if line.startswith("[Progression correction]")
]
self.assertEqual(progress[0], "[Progression correction] Groupes traités : 0/2")
self.assertEqual(progress[-1], "[Progression correction] Groupes traités : 3/3")
def test_live_correction_stops_scheduling_and_waits_for_active_task(self) -> None:
module = self.modules["correction"]
self.addCleanup(module.stop_requested.clear)
with tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory))
args = module.build_parser().parse_args(
[str(workspace.root), "--overwrite"]
)
module.configure_runtime(
workspace,
[("group-1.jpg", "Ex 1"), ("group-2.jpg", "Ex 2")],
args,
api_client=Mock(),
)
processed = []
def process(task, _precomputed=None):
processed.append(task[0])
module.request_graceful_stop()
(workspace.root / "saved-result").write_text(task[0])
return []
with patch.object(module, "NB_THREADS", 1), patch.object(
module, "process_single_task", side_effect=process
), patch.object(
module, "resolve_delayed_moves", return_value=[]
):
status = module.run_configured(args)
saved_result = (workspace.root / "saved-result").read_text()
self.assertEqual(status, module.ExitCode.INTERRUPTED)
self.assertEqual(processed, ["group-1.jpg"])
self.assertEqual(saved_result, "group-1.jpg")
def test_gemini_call_finishes_inflight_stream_but_does_not_retry(self) -> None:
module = self.modules["correction"]
self.addCleanup(module.stop_requested.clear)
module.stop_requested.clear()
api_client = Mock()
def stream(**_kwargs):
yield SimpleNamespace(text="first")
module.stop_requested.set()
yield SimpleNamespace(text="-second")
api_client.models.generate_content_stream.side_effect = stream
with patch.object(module, "client", api_client):
self.assertEqual(
module.call_gemini_with_retries("model", [], Mock()),
"first-second",
)
with self.assertRaises(module.CorrectionStopRequested):
module.call_gemini_with_retries("model", [], Mock())
self.assertEqual(api_client.models.generate_content_stream.call_count, 1)
def test_interrupted_live_correction_resumes_only_remaining_groups(self) -> None:
module = self.modules["correction"]
self.addCleanup(module.stop_requested.clear)
with tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory))
workspace.copies_dir.mkdir()
tasks = []
for index, label in enumerate(("Ex 1", "Ex 2"), start=1):
group_dir = workspace.groups_dir / label
group_dir.mkdir(parents=True)
image = group_dir / f"Group_{index}.jpg"
image.write_bytes(b"image")
atomic_write_json(
image.with_suffix(".json"),
[["01", 0, 400, 1.0, label]],
)
tasks.append((str(image), label))
response = json.dumps(
[{"id": "01", "result": {"error": "", "feedback": []}}]
)
call_count = 0
def stream(**_kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
module.stop_requested.set()
yield SimpleNamespace(text=response)
api_client = Mock()
api_client.models.generate_content_stream.side_effect = stream
args = module.build_parser().parse_args([str(workspace.root)])
with patch.object(module, "NB_THREADS", 1), patch.object(
module.prompting, "generate_request", return_value=([], Mock())
):
module.configure_runtime(
workspace, tasks, args, api_client=api_client
)
first_status = module.run_configured(args)
first_progress = read_json(workspace.correction_progress_file)
first_results = read_json(workspace.correction_file)
module.configure_runtime(
workspace, tasks, args, api_client=api_client
)
pending_on_resume = list(module.tasks_to_process)
second_status = module.run_configured(args)
final_progress = read_json(workspace.correction_progress_file)
final_results = read_json(workspace.correction_file)
self.assertEqual(first_status, module.ExitCode.INTERRUPTED)
self.assertEqual(first_progress, [list(tasks[0])])
self.assertEqual(len(first_results["Ex 1"]), 1)
self.assertEqual(first_results["Ex 2"], [])
self.assertEqual(pending_on_resume, [tasks[1]])
self.assertEqual(second_status, module.ExitCode.SUCCESS)
self.assertEqual(final_progress, [list(tasks[0]), list(tasks[1])])
self.assertEqual(len(final_results["Ex 1"]), 1)
self.assertEqual(len(final_results["Ex 2"]), 1)
self.assertEqual(call_count, 2)
def test_interrupted_label_errors_resume_auxiliary_requests(self) -> None:
module = self.modules["correction"]
self.addCleanup(module.stop_requested.clear)
for error_type in ("wrong-label", "additional-answer"):
with self.subTest(error_type=error_type), tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory))
workspace.copies_dir.mkdir()
group_dir = workspace.groups_dir / "Ex 1"
group_dir.mkdir(parents=True)
image = group_dir / "Group_1.jpg"
image.write_bytes(b"image")
atomic_write_json(
image.with_suffix(".json"),
[["01", 0, 400, 1.0, "Ex 1"]],
)
task = (str(image), "Ex 1")
response = json.dumps(
[
{
"id": "01",
"result": {"error": error_type, "feedback": []},
}
]
)
primary_call_count = 0
def stream(**_kwargs):
nonlocal primary_call_count
primary_call_count += 1
module.stop_requested.set()
yield SimpleNamespace(text=response)
def resolve_error(_pid, _label, result, _pdf_path):
self.assertEqual(result["error"], error_type)
result["error"] = ""
return []
api_client = Mock()
api_client.models.generate_content_stream.side_effect = stream
args = module.build_parser().parse_args(
[str(workspace.root), "--overwrite"]
)
with patch.object(module, "NB_THREADS", 1), patch.object(
module.prompting, "generate_request", return_value=([], Mock())
), patch.object(
module, "handle_label_errors", side_effect=resolve_error
) as handler:
module.configure_runtime(
workspace, [task], args, api_client=api_client
)
first_status = module.run_configured(args)
saved_pending = read_json(
workspace.correction_pending_responses_file
)
first_progress_exists = (
workspace.correction_progress_file.exists()
)
first_correction_exists = workspace.correction_file.exists()
module.configure_runtime(
workspace, [task], args, api_client=api_client
)
second_status = module.run_configured(args)
self.assertEqual(first_status, module.ExitCode.INTERRUPTED)
self.assertFalse(first_progress_exists)
self.assertFalse(first_correction_exists)
self.assertEqual(
json.loads(saved_pending[str(image)]), json.loads(response)
)
self.assertEqual(second_status, module.ExitCode.SUCCESS)
self.assertEqual(primary_call_count, 1)
handler.assert_called_once()
self.assertEqual(
read_json(workspace.correction_progress_file), [list(task)]
)
corrected = read_json(workspace.correction_file)
self.assertEqual(corrected["Ex 1"][0][0]["result"]["error"], "")
self.assertEqual(
read_json(workspace.correction_pending_responses_file), {}
)
def test_correction_reset_restores_old_and_deletes_new_files(self) -> None:
module = self.modules["correction"]
with tempfile.TemporaryDirectory() as directory:
@@ -1255,6 +1607,9 @@ class StandardCliTests(unittest.TestCase):
copy_dir.mkdir(parents=True)
atomic_write_json(evaluation / "correction.json", {"old": True})
atomic_write_json(evaluation / "correction_progress.json", ["old"])
atomic_write_json(
evaluation / "correction_pending_responses.json", {"group": "response"}
)
(copy_dir / "Ex 1.pdf").write_bytes(b"current")
(copy_dir / "Ex 1_old.pdf").write_bytes(b"original")
(copy_dir / "Ex 2_new.pdf").write_bytes(b"generated")
@@ -1262,6 +1617,9 @@ class StandardCliTests(unittest.TestCase):
self.assertEqual(module.main([str(evaluation), "--reset"]), 0)
self.assertFalse((evaluation / "correction.json").exists())
self.assertFalse((evaluation / "correction_progress.json").exists())
self.assertFalse(
(evaluation / "correction_pending_responses.json").exists()
)
self.assertEqual((copy_dir / "Ex 1.pdf").read_bytes(), b"original")
self.assertFalse((copy_dir / "Ex 1_old.pdf").exists())
self.assertFalse((copy_dir / "Ex 2_new.pdf").exists())
@@ -1798,6 +2156,13 @@ class WorkflowTests(unittest.TestCase):
)
self.assertEqual(skip_without_conflicts, {"manual_resolution"})
def test_manual_resolution_precedes_post_correction(self) -> None:
ordered_ids = [step.id for step in build_workflow(False)]
self.assertLess(
ordered_ids.index("manual_resolution"),
ordered_ids.index("post_correction"),
)
def test_crop_auto_start_follows_always_crop_configuration(self) -> None:
for enabled in (False, True):
with self.subTest(enabled=enabled), patch(
@@ -1823,6 +2188,16 @@ class WorkflowTests(unittest.TestCase):
def test_review_persp_has_shorter_title(self) -> None:
self.assertEqual(self.steps["review_persp"].title, "Relire les barèmes")
def test_cutleft_title_and_shortcuts_are_documented(self) -> None:
step = self.steps["cutleft"]
self.assertEqual(
step.title,
"Découper une partie à gauche pour détection des labels",
)
for shortcut in ("n", "N", "t", "l", "1", "Entrée", "s"):
with self.subTest(shortcut=shortcut):
self.assertIn(shortcut, step.description)
def test_export_has_shorter_title_and_annotation_argument(self) -> None:
self.assertEqual(self.steps["export"].title, "Exporter")
command = self.command(
@@ -1857,6 +2232,47 @@ class WorkflowTests(unittest.TestCase):
detected_annotation_directories(evaluation), ("Bnot", "Anot")
)
def test_giving_names_utilities_find_and_rename_problematic_copies(self) -> None:
with tempfile.TemporaryDirectory() as directory:
evaluation = Path(directory)
return_dir = evaluation / "A Rendre"
original_dir = evaluation / "Copies Originales"
return_dir.mkdir()
original_dir.mkdir()
(original_dir / "Copie01.pdf").write_bytes(b"original")
folders = {
"Unknown (01)": (b"unknown-jpg", b"unknown-pdf"),
"Dupont (02)": (b"two-jpg", b"two-pdf"),
"Dupont (03)": (b"three-jpg", b"three-pdf"),
"Unique (04)": (b"four-jpg", b"four-pdf"),
}
for folder_name, (jpg, pdf) in folders.items():
folder = return_dir / folder_name
folder.mkdir()
base_name = folder_name.rsplit(" (", 1)[0]
(folder / f"{base_name}.jpg").write_bytes(jpg)
(folder / f"{base_name}.pdf").write_bytes(pdf)
(folder / "score.json").write_text("{}", encoding="utf-8")
(folder / "answers").mkdir()
issues = find_name_issues(evaluation)
self.assertEqual([issue.copy_id for issue in issues], ["01", "02", "03"])
self.assertEqual(
original_copy_path(evaluation, "01"),
original_dir / "Copie01.pdf",
)
renamed = rename_return_copy(return_dir / "Unknown (01)", "Alice")
self.assertEqual(renamed.name, "Alice (01)")
self.assertEqual((renamed / "Alice.jpg").read_bytes(), b"unknown-jpg")
self.assertEqual((renamed / "Alice.pdf").read_bytes(), b"unknown-pdf")
self.assertTrue((renamed / "score.json").is_file())
self.assertTrue((renamed / "answers").is_dir())
self.assertEqual(
[issue.copy_id for issue in find_name_issues(evaluation)],
["02", "03"],
)
def test_export_and_import_defaults_follow_previous_runs(self) -> None:
with tempfile.TemporaryDirectory() as directory:
evaluation = Path(directory)
@@ -1883,10 +2299,27 @@ class WorkflowTests(unittest.TestCase):
def test_plotting_shortcuts_describe_open_actions(self) -> None:
rendered = "\n".join(plotting_shortcut_lines())
self.assertIn("p : revenir à la précédente", rendered)
self.assertIn("ouvrir l’énoncé", rendered)
self.assertIn("ouvrir la copie traitée", rendered)
self.assertIn("ouvrir la copie originale", rendered)
def test_gui_tracks_correction_progress_across_output_chunks(self) -> None:
app = object.__new__(CopienatorApp)
app.active_step_id = "correction"
app._correction_progress_buffer = ""
app.info_var = Mock()
app._track_correction_progress(
"[Progression correction] Groupes trai"
)
app.info_var.set.assert_not_called()
app._track_correction_progress("tés : 3/8\n")
app.info_var.set.assert_called_once_with(
"Correction : 3 groupe(s) traité(s) sur 8 (38 %)."
)
def test_proxy_is_opt_in_and_prefilled(self) -> None:
base = {"HTTPS_PROXY": "http://system-proxy", "OTHER": "kept"}
without_proxy = build_runner_environment(
@@ -1922,6 +2355,21 @@ class WorkflowTests(unittest.TestCase):
)
self.assertEqual(command[4], "correct")
def test_refaire_correction_does_not_offer_overwrite(self) -> None:
step = self.steps["correction"]
overwrite = next(
argument for argument in step.arguments if argument.name == "overwrite"
)
self.assertNotIn("refaire", overwrite.variants)
command = self.command(
"correction",
"refaire",
{"target": self.evaluation, "overwrite": True},
)
self.assertIn("--refaire", command)
self.assertNotIn("--overwrite", command)
def test_hybrid_correction_arguments(self) -> None:
command = self.command(
"correction", "hybrid", {"target": self.evaluation, "batch_from": "Ex 4"}