Files
Copies/tests/test_statement_personal_flow.py
T
2026-10-21 22:28:27 +02:00

414 lines
23 KiB
Python

import io
import json
import os
import tempfile
import unittest
from pathlib import Path
from tkinter import ttk
from types import SimpleNamespace
from unittest.mock import Mock, patch
from copienator import CliError, EvaluationWorkspace, ExitCode
from copienator.commands import enonce_info as personal
from copienator.commands import gemini_for_enonce as gemini
from copienator_gui.app import CopienatorApp, get_personal_interro_files
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)
(root / "correction.tex").write_text("Not SHEETINFO")
blocks = [{"id": 42, "indexes": [{"indices": [1]}, {"indices": [2]}]},
{"id": 99}]
(root / "enonce.tex").write_text("\n".join(
"%%SHEETINFO : " + json.dumps(block) for block in blocks))
(root / "label_groups").write_text("obsolete groups")
urls = []
def fetch(url):
urls.append(url)
if "/emacs/" in url:
return io.BytesIO(b"Statement\n 1) Question\n###Solution\n 1) Answer\n###Rubric\n 1) Points")
return io.BytesIO(b"Selected exercise content")
def compile_pdf(content, path):
Path(path).write_bytes(b"test PDF")
with patch.object(personal.urllib.request, "urlopen", side_effect=fetch), patch.object(
personal, "compile_to_pdf", side_effect=compile_pdf
):
self.assertEqual(personal.process_directory(EvaluationWorkspace(root)), ExitCode.SUCCESS)
self.assertEqual((root / "labels").read_text(), "Ex 1 : 1)\nEx 1 : 2)\nEx 2\n")
self.assertEqual((root / "label_groups").read_text(), "Ex 1 : 1), Ex 1 : 2)\nEx 2\n")
for folder in ("Text2", "Sol2"):
for label in ("Ex 1 : 1)", "Ex 1 : 2)", "Ex 2"):
self.assertTrue((root / folder / f"{label}.tex").is_file())
self.assertTrue((root / folder / f"{label}.pdf").is_file())
self.assertIn("Rubric", (root / "Persp" / "Ex 1").read_text())
self.assertTrue(any("/exo_q_text/42/1" in url for url in urls))
def test_personal_choice_and_optional_steps_only_in_personal_profile(self):
standard = {step.id: step for step in build_workflow(False)}
enabled = {step.id: step for step in build_workflow(True)}
self.assertEqual([variant.id for variant in standard["statement"].variants], ["gemini"])
self.assertEqual([variant.program for variant in enabled["statement"].variants],
["statement-personal", "statement"])
for ident in ("statement_groups", "statement_persp"):
self.assertNotIn(ident, standard)
self.assertTrue(enabled[ident].optional)
self.assertFalse(enabled[ident].auto_start_first_visit)
class SelectiveGeminiTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.workspace = EvaluationWorkspace(self.root)
self.labels = ["Ex 1 : 1)", "Ex 1 : 2)", "Ex 2"]
(self.root / "labels").write_text("\n".join(self.labels) + "\n")
(self.root / "label_groups").write_text(", ".join(self.labels[:2]) + "\nEx 2\n")
for folder in ("Text", "Sol", "Text2", "Sol2", "Persp"):
(self.root / folder).mkdir()
for label in self.labels:
suffix = ".tex" if folder.endswith("2") else ""
(self.root / folder / (label + suffix)).write_text(
label + '\n' if folder == 'Persp' else f"Personal {folder}: {label}")
(self.root / "Persp" / "Ex 1").write_text("Old aggregate rubric")
self.client = SimpleNamespace(models=SimpleNamespace(generate_content=Mock()))
def snapshot(self):
return {str(path.relative_to(self.root)): path.read_bytes()
for path in self.root.rglob("*") if path.is_file()}
def response(self, value):
return SimpleNamespace(text=json.dumps(value))
def test_grouping_changes_only_groups_and_keeps_exact_labels(self):
before = self.snapshot()
self.client.models.generate_content.return_value = self.response({"groups": [[label] for label in self.labels]})
self.assertEqual(gemini.refine_existing(self.workspace, "groups", api_client=self.client), ExitCode.SUCCESS)
after = self.snapshot()
self.assertEqual(after.pop("label_groups"), ("\n".join(self.labels) + "\n").encode())
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"]]):
self.client.models.generate_content.return_value = self.response({"groups": groups})
with self.assertRaises(CliError):
gemini.refine_existing(self.workspace, "groups", api_client=self.client)
self.assertEqual(self.snapshot(), before)
def rubric_response(self, labels):
return self.response({"rubrics": [{"label": label, "rubric_content": "Barème Gemini sur 4 points"}
for label in labels]})
def test_rubrics_replace_only_persp_using_normal_prompt(self):
before = self.snapshot()
self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]),
self.rubric_response(self.labels[2:])]
self.assertEqual(gemini.refine_existing(self.workspace, "persp", api_client=self.client), ExitCode.SUCCESS)
after = self.snapshot()
self.assertEqual({key: value for key, value in before.items() if not key.startswith("Persp/")},
{key: value for key, value in after.items()
if not key.startswith(("Persp/", "Cache/"))})
responses = list((self.root / 'Cache' / 'rubric_responses').glob('*.json'))
self.assertEqual(len(responses), 2)
self.assertEqual((self.root / "Persp" / "Ex 1").read_bytes(), before['Persp/Ex 1'])
for label in self.labels:
self.assertIn("Barème Gemini", (self.root / "Persp" / label).read_text())
for call in self.client.models.generate_content.call_args_list:
self.assertEqual(call.kwargs["contents"][0].parts[0].text, gemini.PROMPT_4)
self.assertTrue(call.kwargs["config"].automatic_function_calling.disable)
def test_rubric_prompt_omits_the_assumed_total_and_requires_latex(self):
self.assertIn("total est toujours implicite", gemini.PROMPT_4)
self.assertIn("caractères mathématiques Unicode", gemini.PROMPT_4)
self.assertIn(r"$\lfloor \sqrt{k} \rfloor$", gemini.PROMPT_4)
def test_incomplete_or_failed_rubrics_keep_completed_groups(self):
before = self.snapshot()
for last in (self.response({"rubrics": []}), RuntimeError("API unavailable")):
for label in self.labels[:2]:
(self.root / 'Persp' / label).write_bytes(before[f'Persp/{label}'])
self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]), last]
with self.assertRaises((CliError, RuntimeError)):
gemini.refine_existing(self.workspace, "persp", api_client=self.client)
after = self.snapshot()
for label in self.labels[:2]:
self.assertIn(b'Bar\xc3\xa8me Gemini', after[f'Persp/{label}'])
self.assertEqual(after['Persp/Ex 2'], before['Persp/Ex 2'])
self.assertEqual(after['Persp/Ex 1'], before['Persp/Ex 1'])
self.assertEqual({key: value for key, value in before.items() if not key.startswith('Persp/')},
{key: value for key, value in after.items()
if not key.startswith(('Persp/', 'Cache/'))})
def test_rubric_latex_and_line_breaks_survive_json_and_file_writes(self):
expected = (
r'Barème : $f \circ g \rightarrow \frac{1}{2} \not= 0$.'
'\n' + r'$\begin{matrix}a & b \\ c & d\end{matrix}$, $\theta$, $\beta$, $\nu$.'
'\nUne "justification" vaut 1 point.'
)
self.client.models.generate_content.side_effect = [
self.response({'rubrics': [{'label': label, 'rubric_content': expected} for label in group]})
for group in (self.labels[:2], self.labels[2:])
]
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
for label in self.labels:
self.assertEqual((self.root / 'Persp' / label).read_bytes(),
(label + '\n' + expected).encode('utf-8'))
def test_invalid_latex_escapes_are_repaired_without_retry(self):
for command in (r'\circ', r'\rightarrow', r'\frac', r'\not', r'\theta', r'\beta'):
with self.subTest(command=command):
# Deliberately malformed wire JSON: do not json.dumps the command.
broken = SimpleNamespace(text='{"rubrics":[{"label":"Ex 2","rubric_content":"$'
+ command + '$"}]}')
self.client.models.generate_content.reset_mock()
self.client.models.generate_content.side_effect = [broken]
self.assertEqual(gemini.generate_rubrics(self.client, 'context'),
{'Ex 2': '$' + command + '$'})
self.assertEqual(self.client.models.generate_content.call_count, 1)
def test_control_character_keeps_progress_and_saves_diagnostics(self):
before = self.snapshot()
broken = self.response({'rubrics': [{'label': 'Ex 2', 'rubric_content': 'invalid \x01 control'}]})
self.client.models.generate_content.side_effect = [
self.rubric_response(self.labels[:2]), broken]
output = io.StringIO()
with patch('sys.stdout', output), self.assertRaisesRegex(ValueError, 'control characters'):
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
after = self.snapshot()
self.assertEqual(after['Persp/Ex 2'], before['Persp/Ex 2'])
for label in self.labels[:2]:
self.assertIn(b'Bar\xc3\xa8me Gemini', after[f'Persp/{label}'])
self.assertIn('Question Ex 2', output.getvalue())
self.assertIn('U+0001 at offset 8', output.getvalue())
self.assertIn(r'invalid \x01 control', output.getvalue())
responses = list((self.root / 'Cache' / 'rubric_responses').glob('*.json'))
self.assertEqual(len(responses), 2)
self.assertIn(broken.text, [path.read_text() for path in responses])
log = (self.root / 'Cache' / 'rubric_errors.log').read_text()
self.assertIn('Question Ex 2', log)
self.assertIn(r'invalid \x01 control', log)
def test_invalid_json_is_saved_exactly_and_reported(self):
raw = '{"rubrics": [this is malformed JSON'
self.client.models.generate_content.return_value = SimpleNamespace(text=raw)
output = io.StringIO()
with patch('sys.stdout', output), self.assertRaises(ValueError):
gemini.generate_rubrics(self.client, 'context', workspace=self.workspace,
expected_labels=['Ex 2'])
responses = list((self.root / 'Cache' / 'rubric_responses').glob('*.json'))
self.assertEqual(len(responses), 1)
self.assertEqual(responses[0].read_text(), raw)
self.assertIn(raw, output.getvalue())
self.assertIn(str(responses[0]), output.getvalue())
def test_missing_question_only_preserves_existing_and_keeps_group_context(self):
kept = self.root / 'Persp' / self.labels[0]
kept.write_text(self.labels[0] + '\nManually reviewed rubric')
before = kept.read_bytes()
(self.root / 'Persp' / self.labels[1]).write_text('')
(self.root / 'Persp' / self.labels[2]).unlink()
self.client.models.generate_content.side_effect = [
self.rubric_response([self.labels[1]]), self.rubric_response([self.labels[2]])]
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
self.assertEqual(kept.read_bytes(), before)
call = self.client.models.generate_content.call_args_list[0]
text = call.kwargs['contents'][0].parts[1].text
self.assertIn('UNIQUEMENT pour les labels suivants : ' + self.labels[1] + '\n', text)
self.assertIn('Question [' + self.labels[0] + ']', text)
self.assertEqual(self.client.models.generate_content.call_count, 2)
def test_retry_requests_only_failed_group(self):
self.client.models.generate_content.side_effect = [
self.rubric_response(self.labels[:2]), RuntimeError('API unavailable')]
with self.assertRaises(RuntimeError):
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
before = {label: (self.root / 'Persp' / label).read_bytes() for label in self.labels[:2]}
self.client.models.generate_content.reset_mock()
self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[2:])]
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
self.assertEqual(self.client.models.generate_content.call_count, 1)
for label, content in before.items():
self.assertEqual((self.root / 'Persp' / label).read_bytes(), content)
def test_complete_persp_needs_no_api_key_or_requests(self):
for label in self.labels:
(self.root / 'Persp' / label).write_text(label + '\nReviewed rubric')
before = self.snapshot()
with patch.object(gemini, 'api_key', ''), patch.object(gemini.genai, 'Client') as client:
self.assertEqual(gemini.refine_existing(self.workspace, 'persp'), ExitCode.SUCCESS)
client.assert_not_called()
self.assertEqual(self.snapshot(), before)
def test_delete_persp_forces_regeneration_and_removes_old_files(self):
for label in self.labels:
(self.root / 'Persp' / label).write_text(label + '\nReviewed rubric')
self.client.models.generate_content.side_effect = [
self.rubric_response(self.labels[:2]), self.rubric_response(self.labels[2:])]
gemini.refine_existing(self.workspace, 'persp', api_client=self.client, delete_persp=True)
self.assertEqual(self.client.models.generate_content.call_count, 2)
self.assertEqual({path.name for path in (self.root / 'Persp').iterdir()}, set(self.labels))
def test_cli_delete_persp_dispatch(self):
with patch.object(gemini, 'refine_existing', return_value=ExitCode.SUCCESS) as refine:
self.assertEqual(gemini.main([str(self.root), '--persp-only', '--delete-persp']), ExitCode.SUCCESS)
self.assertEqual(refine.call_args.kwargs, {'delete_persp': True})
refine.reset_mock()
self.assertEqual(gemini.main([str(self.root), '--delete-persp']), ExitCode.INVALID_ARGUMENTS)
refine.assert_not_called()
def test_cli_dispatches_selective_modes_without_full_extraction(self):
with patch.object(gemini, "refine_existing", return_value=ExitCode.SUCCESS) as refine, patch.object(
gemini, "process_exam"
) as full:
for flag, mode in (("--groups-only", "groups"), ("--persp-only", "persp")):
self.assertEqual(gemini.main([str(self.root), flag]), ExitCode.SUCCESS)
self.assertEqual(refine.call_args.args[1], mode)
full.assert_not_called()
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
class PersonalStatementGuiTests(unittest.TestCase):
def test_get_file_button_completes_inputs_and_advances(self):
with tempfile.TemporaryDirectory() as temporary:
repository = Path(temporary)
evaluation = repository / "Interro12"
source = repository / "source"
evaluation.mkdir()
source.mkdir()
(repository / "names").touch()
for name, content in (
("Interro12.pdf", b"pdf"),
("Interro12.tex", b"statement"),
("Interro12c.tex", b"correction"),
):
(source / name).write_bytes(content)
app = CopienatorApp(repository, True, evaluation)
try:
app.update()
buttons = [
child
for child in app.form.winfo_children()
if isinstance(child, ttk.Button)
]
get_button = next(
button for button in buttons if button.cget("text") == "Get the files"
)
with patch(
"copienator_gui.app.get_personal_interro_files",
side_effect=lambda target: get_personal_interro_files(target, source),
):
get_button.invoke()
app.update()
self.assertEqual(app.state_store.step("inputs")["status"], "success")
self.assertEqual(app.current_step.id, "statement")
self.assertEqual((evaluation / "enonce.pdf").read_bytes(), b"pdf")
self.assertEqual((evaluation / "enonce.tex").read_bytes(), b"statement")
self.assertEqual((evaluation / "correction.tex").read_bytes(), b"correction")
finally:
for callback in app.tk.splitlist(app.tk.call("after", "info")):
app.after_cancel(callback)
app.destroy()
def test_personal_requirements_and_optional_button_command_previews(self):
with tempfile.TemporaryDirectory() as temporary:
evaluation = Path(temporary)
(evaluation / "enonce.tex").touch()
app = CopienatorApp(Path.cwd(), True, evaluation)
try:
app.update()
app.tree.selection_set("statement")
app.update()
self.assertEqual(app.variant_var.get(), "personal")
self.assertIn("statement-personal", app.command_var.get())
self.assertEqual(app._missing_requirements(app.current_step), [])
self.assertIn("SHEETINFO", app.description_label.cget("text"))
self.assertFalse(
any(
isinstance(child, ttk.LabelFrame)
and child.cget("text") == "Après génération — facultatif"
for child in app.form.winfo_children()
)
)
for ident, flag in (("statement_groups", "--groups-only"), ("statement_persp", "--persp-only")):
app.tree.selection_set(ident)
app.update()
self.assertEqual(app.current_step.id, ident)
self.assertIn(flag, app.command_var.get())
self.assertTrue(app.current_step.optional)
self.assertFalse(app.runner.running)
(evaluation / 'labels').write_text('Ex 1\nEx 2\n')
(evaluation / 'Persp').mkdir()
(evaluation / 'Persp' / 'Ex 1').write_text('Ex 1\nReviewed rubric')
(evaluation / 'Persp' / 'Ex 2').write_text('Ex 2\n')
app._refresh_persp_inventory()
self.assertIn('Barèmes présents : 1/2', app.persp_inventory_label.cget('text'))
self.assertIn('Manquants : Ex 2', app.persp_inventory_label.cget('text'))
self.assertFalse(app.arg_vars['delete_persp'].get())
app.arg_vars['delete_persp'].set(True)
self.assertIn('--delete-persp', app.command_var.get())
app._save_current_form()
app._render_step()
self.assertFalse(app.arg_vars['delete_persp'].get())
self.assertNotIn('--delete-persp', app.command_var.get())
app.tree.selection_set('statement')
app.update()
app._select_variant(1)
app.arg_vars['restart'].set(True)
app._save_current_form()
app._render_step()
self.assertFalse(app.arg_vars['restart'].get())
self.assertNotIn('--restart', app.command_var.get())
finally:
for callback in app.tk.splitlist(app.tk.call("after", "info")):
app.after_cancel(callback)
app.destroy()