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()