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_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(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_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/")}) self.assertFalse((self.root / "Persp" / "Ex 1").exists()) 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_incomplete_or_failed_rubrics_preserve_entire_persp(self): before = self.snapshot() for last in (self.response({"rubrics": []}), RuntimeError("API unavailable")): 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) self.assertEqual(self.snapshot(), before) 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) finally: for callback in app.tk.splitlist(app.tk.call("after", "info")): app.after_cancel(callback) app.destroy()