513 lines
28 KiB
Python
513 lines
28 KiB
Python
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()
|