Copy removal

This commit is contained in:
sebastien committed 2026-11-02 11:35:21 +01:00
1 parent c36642e8de
commit 213576f83d
8 files changed
+464 -26

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image
from copienator.copy_removal import available_copies, removal_plan, remove_copy
class CopyRemovalTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
for name in ('Copies/Copie24.pdf', 'Copies/Copie25.pdf', 'Copies Originales/Copie24.pdf',
'Cutleft/Copie24_01.jpg', 'Copies/Copie24/Ex 1.pdf',
'BGnot/Copie24/Concat.pdf', 'A Rendre/Antoine (24)/score.json',
'.copienator/runs/old/Copies/Copie24.pdf',
'BackUp/previous/Copie24.pdf'):
self.write(name, b'original bytes')
def write(self, name, value):
p = self.root / name
p.parent.mkdir(parents=True, exist_ok=True)
p.write_bytes(value)
return p
def test_removes_all_copy_artifacts_and_preserves_neighbors_and_backups(self):
self.write('Copies/Copie240.pdf', b'other copy')
self.write('Copies/Copie24_01.json', b'{"name": "Antoine"}')
self.write('.copienator/copy_errors.json', b'{"Copie24.pdf":{"plotting":"bad"}, "Copie25.pdf":{"plotting":"keep"}}')
self.write('correction.json', b'{"Ex 1":[[{"id":"24","result":{"score":3}}, {"id":"25","result":{"score":4}}]]}')
self.write('refaire.json', b'[["Copie24", []], ["Copie25", []]]')
self.write('general_remarks.json', b'{"24":"remove", "25":"keep"}')
self.write('batch_requests_flash.jsonl', b'old payload')
self.write('correction_pending_responses.json', b'{"group": "cached raw response"}')
self.write('manual_resolutions.txt', b'Copie24 Ex 1 -> Ex 2\nCopie25 Ex 1 -> Ex 2\n')
before = {p.relative_to(self.root): p.read_bytes() for p in self.root.rglob('*') if p.is_file()}
plan = removal_plan(self.root, 'Copie24')
backup = remove_copy(self.root, 'Copie24')
self.assertEqual(available_copies(self.root), ['Copie25', 'Copie240'])
self.assertFalse((self.root/'correction_pending_responses.json').exists())
self.assertEqual((self.root/'manual_resolutions.txt').read_text(), 'Copie25 Ex 1 -> Ex 2\n')
for p in plan.removed:
self.assertFalse(p.exists())
for relative in [p.relative_to(self.root) for p in (*plan.removed, *plan.updated) if p.relative_to(self.root) in before]:
self.assertEqual((backup / 'files' / relative).read_bytes(), before[relative])
self.assertEqual((self.root / 'Copies/Copie25.pdf').read_bytes(), b'original bytes')
self.assertEqual((self.root / 'BackUp/previous/Copie24.pdf').read_bytes(), b'original bytes')
self.assertEqual(json.loads((self.root / 'correction.json').read_text())['Ex 1'][0], [{'id':'25','result':{'score':4}}])
self.assertEqual(json.loads((self.root / 'refaire.json').read_text()), [['Copie25', []]])
self.assertEqual(json.loads((self.root / 'general_remarks.json').read_text()), {'25':'keep'})
self.assertEqual(json.loads((backup / 'manifest.json').read_text())['status'], 'complete')
def test_group_removal_preserves_other_students_coordinates(self):
path = self.write('Par label/Ex 1/Group_1.json', b'[["24", 50, 100, 1, "Ex 1"], ["25", 160, 210, 1, "Ex 1"]]')
Image.new('RGB', (100, 220), 'blue').save(path.with_suffix('.jpg'))
remove_copy(self.root, 'Copie24')
self.assertEqual(json.loads(path.read_text()), [['25',160,210,1,'Ex 1']])
with Image.open(path.with_suffix('.jpg')) as image:
self.assertGreater(image.getpixel((20,60))[0], 240)
self.assertLess(image.getpixel((20,180))[0], 30)
def test_failure_restores_already_removed_files(self):
from copienator.copy_removal import atomic_write_bytes
self.write('refaire.json', b'[["Copie24", []]]')
original = (self.root/'Copies/Copie24.pdf').read_bytes()
def failing_write(path, data):
if path == self.root/'refaire.json':
raise OSError('simulated full disk')
return atomic_write_bytes(path, data)
with patch('copienator.copy_removal.atomic_write_bytes', side_effect=failing_write):
with self.assertRaises(OSError):
remove_copy(self.root, 'Copie24')
self.assertEqual((self.root/'Copies/Copie24.pdf').read_bytes(), original)
self.assertEqual((self.root/'refaire.json').read_bytes(), b'[["Copie24", []]]')
def test_cleanup_keeps_removal_backups(self):
from copienator import EvaluationWorkspace
from copienator.commands.clean import build_cleanup_plan
self.write('correction.json', b'{}')
self.write('A Rendre/Antoine (24)/Antoine.jpg', b'returned image')
plan = build_cleanup_plan(EvaluationWorkspace(self.root))
saved = self.root/'BackUp/previous/Copie24.pdf'
self.assertIn(saved, plan.kept_files)
self.assertNotIn(saved, plan.deleted_files)
self.assertNotIn(saved.parent, plan.deleted_directories)
def test_invalid_selection_and_external_symlink(self):
for invalid in ('../Copie24', 'Copie99', ''):
with self.assertRaises(ValueError):
removal_plan(self.root, invalid)
with tempfile.TemporaryDirectory() as external:
p = Path(external)/'Copie24.pdf'
p.write_bytes(b'outside')
(self.root/'external').symlink_to(external, target_is_directory=True)
remove_copy(self.root, 'Copie24')
self.assertEqual(p.read_bytes(), b'outside')
@unittest.skipUnless(os.environ.get('DISPLAY'), 'Tk requires a display')
class CopyRemovalGuiTests(unittest.TestCase):
def test_first_visit_skips_without_invalidating_then_revisit_can_remove(self):
from copienator_gui.app import CopienatorApp
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
evaluation = root/'Eval'
evaluation.mkdir()
(evaluation/'Copie24.pdf').write_bytes(b'copy')
app = CopienatorApp(root, False, evaluation)
app.update()
try:
steps = [s.id for s in app.steps]
self.assertEqual(steps[steps.index('rename')+1], 'remove_copy')
app.state_store.update_step('page_splitter', status='success')
step = app.step_by_id['remove_copy']
app._move_selection_from('rename', 1)
app.update()
self.assertEqual(app.current_step.id, 'page_splitter')
self.assertEqual(app.state_store.step('remove_copy')['status'], 'skipped')
self.assertEqual(app.state_store.step('page_splitter')['status'], 'success')
app.current_step = step
app._render_step()
app._handle_first_visit(step)
app.removal_copy_var.set('Copie24')
with patch('copienator_gui.app.messagebox.askyesno', return_value=False):
app._run_current_step()
self.assertTrue((evaluation/'Copie24.pdf').exists())
with patch('copienator_gui.app.messagebox.askyesno', return_value=True):
app._run_current_step()
self.assertFalse((evaluation/'Copie24.pdf').exists())
self.assertEqual(app.state_store.step('page_splitter')['status'], 'stale')
self.assertEqual(len(list((evaluation/'BackUp').glob('*/manifest.json'))), 1)
finally:
for callback in app.tk.splitlist(app.tk.call('after', 'info')):
app.after_cancel(callback)
app.destroy()
+2 -2
View File
@@ -2530,8 +2530,8 @@ class WorkflowTests(unittest.TestCase):
self.assertFalse(default_config.ALWAYS_CROP)
self.assertTrue(config.ALWAYS_CROP)
def test_review_persp_has_shorter_title(self) -> None:
self.assertEqual(self.steps["review_persp"].title, "Relire les barèmes")
def test_rubric_review_is_part_of_generation(self) -> None:
self.assertNotIn("review_persp", self.steps)
def test_cutleft_title_and_shortcuts_are_documented(self) -> None:
step = self.steps["cutleft"]
+22 -6
View File
@@ -82,9 +82,12 @@ class PersonalStatementTests(unittest.TestCase):
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.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-personal", "statement", "statement"])
for ident in ("statement_groups", "statement_persp"):
self.assertNotIn(ident, standard)
self.assertTrue(enabled[ident].optional)
@@ -392,13 +395,26 @@ class PersonalStatementGuiTests(unittest.TestCase):
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.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.assertFalse(app.arg_vars['delete_persp'].get())
self.assertNotIn('--delete-persp', app.command_var.get())
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)