fichiers oubliés
This commit is contained in:
1 parent
d048732613
commit
857efcef18
8 files changed
+722
No files matched your search
@@ -0,0 +1,109 @@
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from concurrent.futures import wait as real_wait
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from copienator import EvaluationWorkspace, ExitCode
|
||||
from copienator.commands import gemini_for_labels as labels
|
||||
|
||||
|
||||
class LabelTimeoutTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.workspace = EvaluationWorkspace(Path(directory.name))
|
||||
(self.workspace.root / "labels").write_text("Ex 1\n")
|
||||
(self.workspace.root / "names").write_text("Student\n")
|
||||
self.workspace.copies_dir.mkdir()
|
||||
(self.workspace.root / "Cutleft").mkdir()
|
||||
self.images = []
|
||||
for name in ("Copie01_01", "Copie01_02", "Copie02_01"):
|
||||
image = self.workspace.root / "Cutleft" / f"{name}.jpg"
|
||||
image.write_bytes(b"image")
|
||||
self.images.append(image)
|
||||
self.client = Mock()
|
||||
self.response = Mock(text='{"name":"Student","list":[]}')
|
||||
|
||||
def group(self, images):
|
||||
return labels.process_copy_group(
|
||||
self.workspace, "Copie01", images, client=self.client,
|
||||
labels_text="Ex 1\n", names_text="Student\n",
|
||||
valid_labels={"Ex 1"}, valid_names={"Student"}, overwrite=False,
|
||||
sleep=lambda _: None, target_interval=0,
|
||||
)
|
||||
|
||||
def test_timeout_recovery_uses_bounded_requests_and_saves_result(self):
|
||||
self.client.models.generate_content.side_effect = [TimeoutError(), self.response]
|
||||
self.assertEqual(self.group(self.images[:1]), 1)
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 2)
|
||||
for call in self.client.models.generate_content.call_args_list:
|
||||
options = call.kwargs["config"].http_options
|
||||
self.assertEqual(options.timeout, 120_000)
|
||||
self.assertEqual(options.retry_options.attempts, 1)
|
||||
self.assertTrue((self.workspace.copies_dir / "Copie01_01.json").exists())
|
||||
|
||||
def test_exhaustion_defers_dependent_pages_but_other_copies_finish_and_resume(self):
|
||||
self.client.models.generate_content.side_effect = [
|
||||
TimeoutError(), TimeoutError(), TimeoutError(), self.response,
|
||||
]
|
||||
output = io.StringIO()
|
||||
with patch.object(labels, "selected_images", return_value=(self.images, [])), redirect_stdout(output):
|
||||
result = labels.run(self.workspace, [], client=self.client,
|
||||
sleep=lambda _: None, max_workers=1)
|
||||
self.assertEqual(result, ExitCode.PARTIAL)
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 4)
|
||||
self.assertFalse((self.workspace.copies_dir / "Copie01_01.json").exists())
|
||||
self.assertFalse((self.workspace.copies_dir / "Copie01_02.json").exists())
|
||||
self.assertTrue((self.workspace.copies_dir / "Copie02_01.json").exists())
|
||||
self.assertIn("Incomplete images", output.getvalue())
|
||||
self.assertIn("Copie01_02.jpg", output.getvalue())
|
||||
self.client.models.generate_content.reset_mock(side_effect=True)
|
||||
self.client.models.generate_content.return_value = self.response
|
||||
with patch.object(labels, "selected_images", return_value=(self.images, [])):
|
||||
result = labels.run(self.workspace, [], client=self.client, sleep=lambda _: None)
|
||||
self.assertEqual(result, ExitCode.SUCCESS)
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 2)
|
||||
|
||||
def test_changing_invalid_labels_cannot_retry_forever(self):
|
||||
self.client.models.generate_content.side_effect = [Mock(text=(
|
||||
'{"name":"Student","list":[{"box_2d":[1,2,3,4],"label":"bad%d"}]}' % index
|
||||
)) for index in range(3)]
|
||||
with self.assertRaises(labels.IncompleteCopyError):
|
||||
self.group(self.images[:1])
|
||||
self.assertEqual(self.client.models.generate_content.call_count, 3)
|
||||
|
||||
def test_progress_reports_waiting_image_and_elapsed_time(self):
|
||||
entered, release = Event(), Event()
|
||||
self.addCleanup(release.set)
|
||||
|
||||
def request(**kwargs):
|
||||
entered.set()
|
||||
if not release.wait(2):
|
||||
raise AssertionError("Test did not release request")
|
||||
return self.response
|
||||
|
||||
first_wait = True
|
||||
|
||||
def wait(futures, timeout):
|
||||
nonlocal first_wait
|
||||
self.assertEqual(timeout, 30)
|
||||
if first_wait:
|
||||
first_wait = False
|
||||
self.assertTrue(entered.wait(2))
|
||||
return set(), set(futures)
|
||||
release.set()
|
||||
return real_wait(futures, timeout=2)
|
||||
|
||||
self.client.models.generate_content.side_effect = request
|
||||
output = io.StringIO()
|
||||
with patch.object(labels, "selected_images", return_value=(self.images[:1], [])), \
|
||||
patch.object(labels, "wait", side_effect=wait), redirect_stdout(output):
|
||||
result = labels.run(self.workspace, [], client=self.client, sleep=lambda _: None)
|
||||
self.assertEqual(result, ExitCode.SUCCESS)
|
||||
self.assertIn("1 copies outstanding", output.getvalue())
|
||||
self.assertIn("Copie01_01.jpg, attempt 1/3", output.getvalue())
|
||||
self.assertIn("elapsed for image", output.getvalue())
|
||||
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from copienator.latex_json import repair_latex_json, normalize_literal_newlines
|
||||
from copienator.commands.gemini_for_enonce import ExamQuestions, ExamSolutions, ExamContext, GroupRubrics
|
||||
|
||||
|
||||
class LatexJsonTests(unittest.TestCase):
|
||||
def test_literal_newlines_preserve_tex_commands_and_row_breaks(self):
|
||||
content = r"Paragraph\n\n Next $\neq \notin \newline$ \\ next \\\n row"
|
||||
expected = "Paragraph\n\n Next " + r"$\neq \notin \newline$ \\ next \\" + "\n row"
|
||||
self.assertEqual(normalize_literal_newlines(content), expected)
|
||||
self.assertEqual(normalize_literal_newlines(expected), expected)
|
||||
raw = json.dumps({'solutions': [{'label': '1)', 'solution_content': content}]})
|
||||
parsed = ExamSolutions.model_validate_json(repair_latex_json(raw))
|
||||
self.assertEqual(parsed.solutions[0].solution_content, expected)
|
||||
|
||||
def test_mixed_correct_and_broken_escapes_in_all_extraction_fields(self):
|
||||
cases = [
|
||||
(ExamQuestions, 'questions', 'question_content', {'label': '1)'}),
|
||||
(ExamSolutions, 'solutions', 'solution_content', {'label': '1)'}),
|
||||
(ExamContext, 'contexts', 'context_content',
|
||||
{'target_question_label': '1)', 'last_question_label': '2)'}),
|
||||
(GroupRubrics, 'rubrics', 'rubric_content', {'label': '1)'})]
|
||||
raw_content = r'$\circ \rightarrow \frac{1}{2} \not= \theta \begin{x} \unknown \underline{x}$\nSuite : $\\sqrt{x}$'
|
||||
expected = r'$\circ \rightarrow \frac{1}{2} \not= \theta \begin{x} \unknown \underline{x}$' + '\n' + r'Suite : $\sqrt{x}$'
|
||||
for model, collection, field, labels in cases:
|
||||
with self.subTest(field=field):
|
||||
raw = json.dumps({collection: [{**labels, field: 'PLACEHOLDER'}]}).replace('PLACEHOLDER', raw_content)
|
||||
repaired = repair_latex_json(raw)
|
||||
parsed = model.model_validate_json(repaired)
|
||||
self.assertEqual(getattr(getattr(parsed, collection)[0], field), expected)
|
||||
self.assertEqual(repair_latex_json(repaired), repaired)
|
||||
|
||||
def test_valid_json_is_unchanged(self):
|
||||
content = 'Énoncé "cité"\nNouvelle ligne\ntexte\nnext\ni \ne \nu \tindent\r\n' + r'$\not= \frac{1}{2} \\ \begin{array}$'
|
||||
raw = json.dumps({'question_content': content, 'label': r'\not'})
|
||||
self.assertEqual(repair_latex_json(raw), raw)
|
||||
self.assertEqual(repair_latex_json(r'{"rubric_content":"\u00e9 \/ \"ok\""}'),
|
||||
r'{"rubric_content":"\u00e9 \/ \"ok\""}')
|
||||
|
||||
def test_only_content_values_are_repaired(self):
|
||||
raw = r'{"label":"\not", "other":"\frac", "rubric_content":"\frac"}'
|
||||
self.assertEqual(repair_latex_json(raw),
|
||||
r'{"label":"\not", "other":"\frac", "rubric_content":"\\frac"}')
|
||||
|
||||
def test_does_not_repair_structural_errors(self):
|
||||
with self.assertRaises(json.JSONDecodeError):
|
||||
json.loads(repair_latex_json(r'{"question_content":"\frac", broken}'))
|
||||
@@ -0,0 +1,70 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json, read_json
|
||||
from copienator.commands.submit_batches import run
|
||||
|
||||
|
||||
class BatchSubmissionResumeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.workspace = EvaluationWorkspace(Path(directory.name))
|
||||
for tier in ("flash", "pro"):
|
||||
(self.workspace.root / f"batch_requests_{tier}.jsonl").write_text("{}\n")
|
||||
self.client = Mock()
|
||||
self.client.files.upload.return_value = SimpleNamespace(name="files/input")
|
||||
self.client.batches.create.return_value = SimpleNamespace(name="batches/flash")
|
||||
|
||||
def test_restart_after_second_upload_failure_preserves_first_job(self):
|
||||
self.client.files.upload.side_effect = [
|
||||
SimpleNamespace(name="files/input"), ConnectionResetError("reset"),
|
||||
]
|
||||
with self.assertRaises(ConnectionResetError):
|
||||
run(self.workspace, client=self.client)
|
||||
original = read_json(self.workspace.batch_jobs_file)["jobs"]["flash"]
|
||||
self.assertEqual(original["name"], "batches/flash")
|
||||
|
||||
self.client.reset_mock()
|
||||
self.client.files.upload.side_effect = None
|
||||
self.client.batches.create.return_value = SimpleNamespace(name="batches/pro")
|
||||
self.assertEqual(run(self.workspace, client=self.client), ExitCode.SUCCESS)
|
||||
self.client.files.upload.assert_called_once()
|
||||
self.assertEqual(self.client.files.upload.call_args.kwargs["file"],
|
||||
str(self.workspace.root / "batch_requests_pro.jsonl"))
|
||||
self.client.batches.create.assert_called_once()
|
||||
jobs = read_json(self.workspace.batch_jobs_file)["jobs"]
|
||||
self.assertEqual(jobs["flash"], original)
|
||||
self.assertEqual(jobs["pro"]["name"], "batches/pro")
|
||||
|
||||
self.client.reset_mock()
|
||||
self.assertEqual(run(self.workspace, client=self.client), ExitCode.SUCCESS)
|
||||
self.client.files.upload.assert_not_called()
|
||||
self.client.batches.create.assert_not_called()
|
||||
|
||||
def test_legacy_manifest_is_skipped_and_explicit_overwrite_resubmits(self):
|
||||
atomic_write_json(self.workspace.batch_jobs_file, {"jobs": {
|
||||
"flash": {"name": "batches/old-flash"},
|
||||
"pro": {"name": "batches/old-pro"},
|
||||
}})
|
||||
self.assertEqual(run(self.workspace, client=self.client), ExitCode.SUCCESS)
|
||||
self.client.files.upload.assert_not_called()
|
||||
self.assertEqual(run(self.workspace, client=self.client, overwrite=True), ExitCode.SUCCESS)
|
||||
self.assertEqual(self.client.batches.create.call_count, 2)
|
||||
|
||||
def test_invalid_manifest_does_not_trigger_duplicate_submission(self):
|
||||
for manifest in ({"jobs": []}, {"jobs": {"flash": {}}}):
|
||||
with self.subTest(manifest=manifest):
|
||||
atomic_write_json(self.workspace.batch_jobs_file, manifest)
|
||||
with self.assertRaises(CliError):
|
||||
run(self.workspace, client=self.client)
|
||||
self.client.files.upload.assert_not_called()
|
||||
|
||||
def test_empty_inputs_report_partial(self):
|
||||
for tier in ("flash", "pro"):
|
||||
(self.workspace.root / f"batch_requests_{tier}.jsonl").write_text("")
|
||||
self.assertEqual(run(self.workspace, client=self.client), ExitCode.PARTIAL)
|
||||
self.client.files.upload.assert_not_called()
|
||||
@@ -0,0 +1,31 @@
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from copienator_gui.syncthing import SERVICE, control_service
|
||||
|
||||
|
||||
class SyncthingTests(unittest.TestCase):
|
||||
def test_active_service_is_not_restarted(self):
|
||||
with patch("copienator_gui.syncthing.service_status", return_value="active"), \
|
||||
patch("copienator_gui.syncthing.subprocess.run") as run:
|
||||
self.assertEqual(control_service("ensure"), "active")
|
||||
run.assert_not_called()
|
||||
|
||||
def test_inactive_service_is_started_and_verified(self):
|
||||
with patch("copienator_gui.syncthing.service_status", side_effect=["inactive", "active"]), \
|
||||
patch("copienator_gui.syncthing.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run:
|
||||
self.assertEqual(control_service("ensure"), "active")
|
||||
self.assertEqual(run.call_args.args[0], ["systemctl", "--no-ask-password", "start", SERVICE])
|
||||
|
||||
def test_failed_start_reports_error(self):
|
||||
with patch("copienator_gui.syncthing.service_status", return_value="inactive"), \
|
||||
patch("copienator_gui.syncthing.subprocess.run", return_value=subprocess.CompletedProcess([], 1, stderr="Access denied")):
|
||||
with self.assertRaisesRegex(RuntimeError, "Access denied"):
|
||||
control_service("ensure")
|
||||
|
||||
def test_stop_targets_system_instance(self):
|
||||
with patch("copienator_gui.syncthing.service_status", return_value="inactive"), \
|
||||
patch("copienator_gui.syncthing.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run:
|
||||
self.assertEqual(control_service("stop"), "inactive")
|
||||
self.assertEqual(run.call_args.args[0], ["systemctl", "--no-ask-password", "stop", SERVICE])
|
||||
Reference in new issue
Block a user