start-gui.sh et améliorations diverses

This commit is contained in:
2026-09-08 12:04:18 +02:00
parent 06d7bad04e
commit db4ed2ef31
14 changed files with 697 additions and 50 deletions
+75
View File
@@ -0,0 +1,75 @@
import os
import tempfile
import unittest
from pathlib import Path
from copienator_gui.app import CopienatorApp
from copienator_gui.workflow import command_display
from copienator.commands.page_splitter import _selected_inputs
from copienator import EvaluationWorkspace
@unittest.skipUnless(os.environ.get("DISPLAY"), "Tk requires a display")
class GuiConvenienceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.repository = Path(self.temp.name)
self.evaluation = self.repository / "Évaluation avec espaces"
self.evaluation.mkdir()
self.app = CopienatorApp(self.repository, False, self.evaluation)
self.app.update()
def tearDown(self):
for callback in self.app.tk.splitlist(self.app.tk.call("after", "info")):
self.app.after_cancel(callback)
self.app.destroy()
self.temp.cleanup()
def test_reload_detects_added_and_removed_files_without_advancing(self):
for name in ("enonce.pdf", "enonce.tex", "correction.tex"):
(self.evaluation / name).touch()
self.app._reload_inputs()
self.assertIn("names", self.app.info_var.get())
(self.repository / "names").touch()
self.app._reload_inputs()
self.app.update()
self.assertEqual(self.app.state_store.step("inputs")["status"], "success")
self.assertEqual(self.app.current_step.id, "inputs")
(self.evaluation / "enonce.pdf").unlink()
self.app._reload_inputs()
self.assertEqual(self.app.state_store.step("inputs")["status"], "ready")
self.assertIn("enonce.pdf", self.app.description_label.cget("text"))
def test_redo_targets_selected_copy_and_copies_runnable_command(self):
for folder in ("Copies", "Copies Originales"):
(self.evaluation / folder).mkdir()
for name in ("Copie01.pdf", "Copie02.pdf"):
(self.evaluation / folder / name).touch()
self.app.tree.selection_set("page_splitter")
self.app.update()
self.app.copy_var.set("Copie02.pdf")
self.app._redo_selected_pages()
command = self.app._make_command()
target = Path(command[-1])
self.assertEqual(target, self.evaluation / "Copies" / "Copie02.pdf")
self.assertEqual(_selected_inputs(EvaluationWorkspace(self.evaluation), target),
[self.evaluation / "Copies Originales" / "Copie02.pdf"])
self.app._copy_command()
self.assertEqual(self.app.clipboard_get(), command_display(command))
self.app._target_all_pages()
self.assertEqual(self.app.arg_vars["target"].get(), self.app._evaluation_arg())
def test_console_selection_survives_output_and_is_read_only(self):
self.app._append_console("Première ligne\nDeuxième ligne\n")
self.app.console.tag_add("sel", "1.0", "1.end")
self.app._append_console("Suite\n")
self.app._copy_console_selection()
self.assertEqual(self.app.clipboard_get(), "Première ligne")
self.app._copy_console_all()
expected = "Première ligne\nDeuxième ligne\nSuite\n"
self.assertEqual(self.app.clipboard_get(), expected)
self.app.console.insert("end", "unwanted edit")
self.assertEqual(self.app.console.get("1.0", "end-1c"), expected)
self.app._select_console_all()
self.app._copy_console_selection()
self.assertEqual(self.app.clipboard_get(), expected)
+1 -1
View File
@@ -812,7 +812,7 @@ class StandardCliTests(unittest.TestCase):
evaluation = Path(directory) / "Exam"
copy_pdf = evaluation / "Copies" / "Copie01.pdf"
copy_pdf.parent.mkdir(parents=True)
document = module.fitz.open()
document = module.pymupdf.open()
page = document.new_page(width=600, height=800)
page.insert_text((100, 200), "Student answer")
document.save(copy_pdf)
+81
View File
@@ -0,0 +1,81 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock
import pymupdf
from copienator.commands.page_splitter import PDFPreviewer, PAGE_SPLITTER_KB
class ReversePagesTests(unittest.TestCase):
def test_reverse_restarts_with_fresh_settings_and_exports_reversed_pages(self):
with tempfile.TemporaryDirectory() as temporary:
source = Path(temporary) / "copy.pdf"
with pymupdf.open() as document:
for index in range(3):
page = document.new_page(width=200 + 20 * index, height=300)
page.insert_text((30, 30), f"Page {index + 1}")
document.save(source)
original = source.read_bytes()
preview = PDFPreviewer.__new__(PDFPreviewer)
preview.doc = pymupdf.open(source)
self.addCleanup(lambda: None if preview.doc.is_closed else preview.doc.close())
preview.current_page_index = 2
preview.page_settings = [{"keep": "none"}, {"keep": "left"}]
preview.current_rotation = 180
preview.file_rotation = 180
preview.global_rotation = 180
preview.processing = False
preview.load_page = Mock()
preview.reverse_pages()
self.assertEqual([page.get_text().strip() for page in preview.doc],
["Page 3", "Page 2", "Page 1"])
self.assertEqual(preview.current_page_index, 0)
self.assertEqual(preview.page_settings, [])
self.assertEqual(preview.current_line_x, 120)
self.assertEqual(preview.current_rotation, 0)
self.assertEqual((preview.file_rotation, preview.global_rotation), (180, 180))
preview.load_page.assert_called_once_with()
self.assertEqual(source.read_bytes(), original)
preview.reverse_pages()
self.assertEqual([page.get_text().strip() for page in preview.doc],
["Page 1", "Page 2", "Page 3"])
preview.reverse_pages()
preview.base_name = "copy"
preview.split_dir = Path(temporary) / "split"
preview.reorder_dir = Path(temporary) / "reorder"
preview.final_file = Path(temporary) / "result.pdf"
preview.output_dir = None
preview.page_settings = [
{"keep": "as_is", "rotation": 0, "line_x": page.rect.width / 2}
for page in preview.doc
]
preview.split_pdf()
preview.reorder_pdfs()
preview.concate_files()
with pymupdf.open(preview.final_file) as result:
self.assertEqual([page.get_text().strip() for page in result],
["Page 3", "Page 2", "Page 1"])
def test_single_page_can_restart_and_processing_ignores_shortcut(self):
preview = PDFPreviewer.__new__(PDFPreviewer)
preview.doc = pymupdf.open()
self.addCleanup(preview.doc.close)
preview.doc.new_page()
preview.processing = True
preview.load_page = Mock()
preview.reverse_pages()
preview.load_page.assert_not_called()
preview.processing = False
preview.reverse_pages()
self.assertEqual(preview.current_page_index, 0)
self.assertEqual(preview.page_settings, [])
preview.load_page.assert_called_once_with()
def test_shortcut_available_with_personal_configuration(self):
self.assertEqual(PAGE_SPLITTER_KB["reverse_pages"], "i")
+165
View File
@@ -0,0 +1,165 @@
import io
import json
import os
import tempfile
import unittest
from pathlib import Path
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
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", "statement-personal"])
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)
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_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()
app._select_variant(1)
self.assertIn("statement-personal", app.command_var.get())
self.assertEqual(app._missing_requirements(app.current_step), [])
self.assertIn("SHEETINFO", app.description_label.cget("text"))
for ident, flag in (("statement_groups", "--groups-only"), ("statement_persp", "--persp-only")):
app._select_statement_action(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()