import io import json import os import tempfile import threading 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", "compile"]) self.assertEqual([variant.id for variant in enabled["statement"].variants], ["personal", "gemini", "compile"]) self.assertEqual([variant.program for variant in enabled["statement"].variants], ["statement-personal", "statement", "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 test_rubric_batches_preserve_groups_and_six_question_limit(self): for sizes, expected in ( ([2, 2, 2], [6]), ([4, 3, 2, 1], [4, 6]), ([2, 7, 1, 5], [2, 7, 6]), ([], []), ): with self.subTest(sizes=sizes): groups = [[f'{index}:{question}' for question in range(size)] for index, size in enumerate(sizes)] original = [group[:] for group in groups] batches = gemini.batch_rubric_groups(groups) self.assertEqual([len(batch) for batch in batches], expected) self.assertEqual([label for batch in batches for label in batch], [label for group in groups for label in group]) for group in groups: self.assertTrue(any(all(label in batch for label in group) for batch in batches)) self.assertEqual(groups, original) 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.return_value = self.rubric_response(self.labels) 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), 1) 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 separate_requests(self, last): # A full six-question group cannot be combined with the final group. extra = [f'Context {index}' for index in range(4)] self.workspace.labels_file.write_text('\n'.join(self.labels + extra) + '\n') self.workspace.label_groups_file.write_text( ', '.join(self.labels[:2] + extra) + '\n' + self.labels[2] + '\n') for label in extra: for directory in ('Text2', 'Sol2'): (self.root / directory / f'{label}.tex').write_text(label) (self.root / 'Persp' / label).write_text(label + '\nReviewed rubric') def response_for_request(**kwargs): text = kwargs['contents'][0].parts[1].text if 'Question [Ex 2]' in text: if isinstance(last, Exception): raise last return last return self.rubric_response(self.labels[:2]) self.client.models.generate_content.side_effect = response_for_request 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.separate_requests(last) before = self.snapshot() 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/', 'Cache/'))}, {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,) ] 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.separate_requests(broken) before = self.snapshot() 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.return_value = self.rubric_response(self.labels[1:]) 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 : ' + ', '.join(self.labels[1:]) + '\n', text) self.assertIn('Question [' + self.labels[0] + ']', text) self.assertEqual(self.client.models.generate_content.call_count, 1) def test_retry_requests_only_failed_group(self): self.separate_requests(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.config, '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_four_concurrent_requests_save_successes_and_retry_only_failure(self): labels = [f'Q{index}' for index in range(20)] groups = [labels[index:index + 4] for index in range(0, 20, 4)] self.workspace.labels_file.write_text('\n'.join(labels) + '\n') group_text = ''.join(', '.join(group) + '\n' for group in groups) self.workspace.label_groups_file.write_text(group_text) for label in labels: for directory in ('Text2', 'Sol2'): (self.root / directory / f'{label}.tex').write_text(label) barrier = threading.Barrier(4, timeout=10) lock = threading.Lock() active = 0 peak = 0 def generate(client, context, *, workspace, expected_labels): nonlocal active, peak self.assertIn(expected_labels, groups) if expected_labels == groups[-1]: raise RuntimeError('API unavailable') with lock: active += 1 peak = max(peak, active) try: barrier.wait() return {label: 'Generated rubric' for label in expected_labels} finally: with lock: active -= 1 with patch.object(gemini, 'generate_rubrics', side_effect=generate) as requests: with self.assertRaisesRegex(RuntimeError, 'API unavailable'): gemini.refine_existing(self.workspace, 'persp', api_client=self.client) self.assertEqual(requests.call_count, 5) self.assertEqual(peak, 4) for label in labels[:16]: self.assertEqual((self.root / 'Persp' / label).read_text(), label + '\nGenerated rubric') self.assertFalse((self.root / 'Persp' / labels[-1]).exists()) self.client.models.generate_content.return_value = self.rubric_response(groups[-1]) gemini.refine_existing(self.workspace, 'persp', api_client=self.client) self.assertEqual(self.client.models.generate_content.call_count, 1) self.assertEqual(self.workspace.label_groups_file.read_text(), group_text) for label in labels: self.assertTrue((self.root / 'Persp' / label).is_file()) 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.return_value = self.rubric_response(self.labels) gemini.refine_existing(self.workspace, 'persp', api_client=self.client, delete_persp=True) self.assertEqual(self.client.models.generate_content.call_count, 1) 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.assertTrue(app.arg_vars['delete_persp'].get()) self.assertIn('--delete-persp', app.command_var.get()) app.arg_vars['delete_persp'].set(False) app._save_current_form() app._render_step() self.assertTrue(app.arg_vars['delete_persp'].get()) self.assertIn('--delete-persp', app.command_var.get()) folder_button = next(child for child in app.form.winfo_children() if isinstance(child, ttk.Button) and child.cget('text') == 'Ouvrir le dossier Persp') with patch('copienator_gui.app.open_path') as opened: folder_button.invoke() opened.assert_called_once_with(evaluation / 'Persp') with patch.object(app, '_move_selection_from') as advance: app.active_step_id = 'statement_persp' app._finish_process(0, False) app.update() advance.assert_not_called() self.assertEqual(app.current_step.id, 'statement_persp') self.assertEqual(app.state_store.step('statement_persp')['status'], 'success') 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()