from __future__ import annotations from io import BytesIO, StringIO import os from pathlib import Path import subprocess import sys import tempfile import unittest from unittest.mock import patch from fastapi.testclient import TestClient from PIL import Image from copienator import CliError, EvaluationWorkspace, atomic_write_json, read_json from copienator.annotation_modifications import APPLIED, DRAFT, stage_baseline from copienator.commands import modify_annotations, giving_names from copienator.evaluation_lock import EvaluationLock from copienator.filesystem import staged_files from copienator.finalisation import Finalisation from copienator.finalisation_server import create_app from copienator.return_identity import rename_return_copy from copienator.score_editing import read_scores, set_score def seed(root, mode='Bnot'): (root / 'Copies' / 'Copie01').mkdir(parents=True, exist_ok=True) (root / 'labels').write_text('Ex 1\nEx 2\nAbsent\n') atomic_write_json(root / 'Copies' / 'Copie01.json', {'name': 'Unknown'}) scores = {'Ex 1': '3', 'Ex 2': '4', 'Absent': ''} info = {label: {'present': label != 'Absent', 'not_empty': label != 'Absent', 'touched': False, 'score': score} for label, score in scores.items()} images = {'Ex 1': Image.new('RGB', (100, 120), 'blue'), 'Ex 2': Image.new('RGB', (100, 80), 'green')} output = root / mode / 'Copie01' with staged_files(output) as staging: atomic_write_json(staging / 'score.json', scores) atomic_write_json(staging / 'info.json', info) for label, image in images.items(): image.save(staging / f'{label}.jpg') images['Ex 1'].save(staging / 'Concat.jpg') stage_baseline(root, mode, output, staging, images, {'Ex 1': 20, 'Ex 2': 10}, info, [], scores) class WebTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) seed(self.root) self.service = Finalisation(self.root, 'Bnot', 'worker-token') self.client = TestClient(create_app(self.service, 'browser-token')) self.addCleanup(self.client.close) self.client.get('/?token=browser-token') def save(self, kind, value, copy='01', label='Ex 1'): response = self.client.post('/api/change', json={'kind': kind, 'value': value, 'copy': copy, 'label': label, 'revision': self.service.revision()}) self.assertEqual(response.status_code, 200, response.text) return response.json() def test_names_follow_gui_repository_even_when_package_is_elsewhere(self): repository = self.root / 'application-folder' repository.mkdir() (repository / 'names').write_text('Alice\nBob\n', encoding='utf-8') service = Finalisation(self.root, 'Bnot', 'token', repository=repository) self.assertEqual(service.state()['expectedNames'], ['Alice', 'Bob']) (self.root / 'names').write_text('Claire\n', encoding='utf-8') self.assertEqual(service.state()['expectedNames'], ['Claire']) def test_expected_names_use_local_roster_and_refresh_after_name_save(self): (self.root / 'names').write_text('Alice\nBob\nAlice\n\n', encoding='utf-8') state = self.client.get('/api/state').json() self.assertEqual(state['expectedNames'], ['Alice', 'Bob']) state = self.save('name', 'Alice') self.assertEqual(state['copies'][0]['name'], 'Alice') self.assertEqual(state['expectedNames'], ['Alice', 'Bob']) (self.root / 'names').write_text('', encoding='utf-8') self.assertEqual(self.service.state()['expectedNames'], []) def test_session_origin_and_paths(self): outsider = TestClient(create_app(self.service, 'browser-token')) self.assertEqual(outsider.get('/api/state').status_code, 403) self.assertEqual(self.client.get('/api/state', headers={'Host': 'evil.example'}).status_code, 403) self.assertEqual(self.client.post('/api/stop', headers={'Origin':'https://evil.example'}).status_code, 403) self.assertEqual(self.client.get('/api/image', params={'copy':'../', 'label':'Ex 1'}).status_code, 409) self.assertEqual(self.client.get('/api/image', params={'copy':'01', 'label':'../labels'}).status_code, 409) self.assertEqual(self.client.get('/api/document', params={'copy':'01', 'kind':'question','label':'Absent'}).status_code, 404) self.assertEqual(self.client.get('/').status_code, 200) def test_browser_and_existing_editor_share_scores_and_remarks(self): for mode in ('Bnot', 'BGnot'): with self.subTest(mode=mode): seed(self.root, mode) self.service = Finalisation(self.root, mode, 'worker-token') self.client = TestClient(create_app(self.service, 'browser-token')) self.client.get('/?token=browser-token') self.save('score', '2,5') score = self.root / mode / 'Copie01/score.json' self.assertEqual(read_scores(score)['Ex 1'], '2.5') set_score(score, 'Ex 1', '1') self.assertEqual(self.service.state()['copies'][0]['answers'][0]['score'], '1') self.save('remark', 'Une remarque $x^2$.') self.save('general', ['Première', 'Deuxième']) self.save('general', ['Deuxième', 'Première']) self.assertEqual(self.service.state()['copies'][0]['general'], ['Deuxième','Première']) self.save('remark', '') self.assertEqual(self.service.state()['copies'][0]['answers'][0]['remark'], '') def test_stale_tab_and_busy_writes_rejected(self): old = self.service.revision() self.save('score', '2') response = self.client.post('/api/change', json={'kind':'score','value':'1','copy':'01','label':'Ex 1','revision':old}) self.assertEqual(response.status_code, 409) self.service.job['running'] = True self.assertEqual(self.client.post('/api/stop').status_code, 409) response = self.client.post('/api/change', json={'kind':'score','value':'1','copy':'01','label':'Ex 1','revision':self.service.revision()}) self.assertEqual(response.status_code, 409) self.service.job['running'] = False def test_draft_preview_apply_reapply_and_undo(self): text = '### Source: Bnot\nCopie01 Ex 1 c{40}1> Ex 2|\n' before = (self.root / 'Bnot/Copie01/Ex 1.jpg').read_bytes() self.save('draft', text) self.assertEqual((self.root / 'Bnot/Copie01/Ex 1.jpg').read_bytes(), before) self.assertFalse((self.root / 'Bnot' / APPLIED).exists()) result = self.client.post('/api/preview', json={'copy':'01','source':'Ex 1','target':'Ex 2','text':text}) self.assertEqual(result.status_code, 200, result.text) import base64 rendered = Image.open(BytesIO(base64.b64decode(result.json()['Ex 1'].split(',')[1]))) self.assertEqual(rendered.height, 40) # cut uses 100px body, not 120px image modify_annotations.run(EvaluationWorkspace(self.root), annotation_dir='Bnot') applied = (self.root / 'Bnot/Copie01/Ex 2.jpg').read_bytes() modify_annotations.run(EvaluationWorkspace(self.root), annotation_dir='Bnot') self.assertEqual((self.root / 'Bnot/Copie01/Ex 2.jpg').read_bytes(), applied) self.save('draft', '### Source: Bnot\n') self.assertEqual((self.root / 'Bnot' / APPLIED).read_text(), text) modify_annotations.run(EvaluationWorkspace(self.root), annotation_dir='Bnot') self.assertEqual(Image.open(self.root / 'Bnot/Copie01/Ex 1.jpg').height, 120) def test_invalid_draft_preserves_previous_file(self): self.save('draft', '### Source: Bnot\n') for text in ('### Source: BGnot\n', '### Source: Bnot\nCopie01 Ex 1 -> Ex 2|\nCopie01 Ex 2 -> Absent|\n'): response = self.client.post('/api/change', json={'kind':'draft','value':text,'revision':self.service.revision()}) self.assertEqual(response.status_code, 409) self.assertEqual((self.root / DRAFT).read_text(), '### Source: Bnot\n') @patch('copienator.commands.giving_names.configuration.RETURN_ANSWERS_ENABLED', False) def test_names_and_fallback_scores_survive_both_workflows(self): workspace = EvaluationWorkspace(self.root) self.save('name', 'Alice') self.assertEqual(giving_names.prepare_named_returns(workspace, 'Bnot'), 0) folder = rename_return_copy(self.root / 'A Rendre/Alice (01)', 'Alice corrigée') self.assertEqual(self.service.state()['copies'][0]['name'], 'Alice corrigée') self.assertEqual(giving_names.prepare_named_returns(workspace, 'Bnot'), 0) self.assertEqual(list((self.root / 'A Rendre').iterdir()), [folder]) score = folder / 'score.json' score.unlink() atomic_write_json(score, {'Ex 1':'3','Ex 2':'4','Absent':''}) set_score(score, 'Ex 1', '1') self.assertEqual(read_scores(self.root / 'Bnot/Copie01/score.json')['Ex 1'], '1') self.save('score', '2') self.assertEqual(read_scores(score)['Ex 1'], '2') self.save('name', 'Alice finale') self.assertTrue((self.root / 'A Rendre/Alice finale (01)/Alice finale.jpg').is_file()) def test_worker_order_flags_and_failure(self): calls = [] class Process: stdout = StringIO('done\n') def __init__(self, command, **kwargs): calls.append(command) def __enter__(self): return self def __exit__(self, *args): pass def wait(self): return 0 with patch('copienator.finalisation.subprocess.Popen', Process): self.service._regenerate(True) self.assertEqual([c[3] for c in calls], ['read-annotations','modify-annotations','giving-names']) self.assertTrue(any('return-answers-question' in v for v in calls[0])) self.assertEqual(self.service.job['status'], 'success') calls.clear() with patch.object(Process, 'wait', return_value=4), patch('copienator.finalisation.subprocess.Popen', Process): self.service._regenerate(True) self.assertEqual(len(calls), 1) self.assertEqual(self.service.job['status'], 'failed') def test_names_only_skip_rendering_before_first_cache_and_after_restart(self): from copienator.review_snapshot import stage output = self.root / 'Bnot/Copie01' data = {'Ex 1': {'pdf_path': self.root/'Copies/Copie01/Ex 1.pdf', 'result': {'score': 3, 'feedback': []}, 'coordinates': [0, 0]}} with staged_files(output) as staging: stage(output, staging, data, {}, engine='standard', bnote={'images': []}) self.save('name', 'Alice') self.assertFalse((output / '.reviewed-render-state.json').exists()) reopened = Finalisation(self.root, 'Bnot', 'token') calls = [] class Process: def __init__(self, command, **kwargs): calls.append(command[3]) self.stdout = StringIO('done\n') def __enter__(self): return self def __exit__(self, *args): pass def wait(self): return 0 with patch('copienator.finalisation.subprocess.Popen', Process): reopened._regenerate(False) self.assertEqual(calls, ['giving-names']) self.save('name', 'Bob') # A score edited outside the session must invalidate the names-only shortcut. set_score(output/'score.json', 'Ex 1', '1') calls.clear() with patch('copienator.finalisation.subprocess.Popen', Process): self.service._regenerate(False) self.assertEqual(calls, ['regenerate-reviewed', 'giving-names']) def test_os_lock_blocks_cli_and_releases(self): with EvaluationLock(self.root): with self.assertRaises(CliError): with EvaluationLock(self.root): pass result = subprocess.run([sys.executable,'-m','copienator','giving-names',str(self.root),'Bnot'], capture_output=True, text=True) self.assertNotEqual(result.returncode, 0) self.assertIn('utilisée', result.stderr) with EvaluationLock(self.root): pass class SnapshotTests(unittest.TestCase): def test_grouped_layout_without_handwriting_replaces_previous_ink(self): from copienator.review_snapshot import stage, load from copienator.annotation_layout import legacy_layout with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) seed(root, 'BGnot') output = root / 'BGnot/Copie01' data = {label: {'pdf_path': root / f'{label}.pdf', 'result': {'score': 3}} for label in ('Ex 1', 'Ex 2')} ink = Image.new('RGBA', (100, 120), 'red') notes = {label: {'img': ink, 'old_header_h': 20, 'layout': legacy_layout()} for label in data} with staged_files(output) as staging: stage(output, staging, data, notes, engine='grouped') notes['Ex 1']['img'] = None for selected in ({'Ex 1'}, None): with staged_files(output) as staging: stage(output, staging, data, notes, engine='grouped', selected=selected) saved, _, restored = load(root, output) self.assertIsNone(saved['notes']['Ex 1']['file']) self.assertEqual(restored['Ex 1'], notes['Ex 1']) self.assertEqual(restored['Ex 2']['img'].getpixel((0, 0)), (255, 0, 0, 255)) def test_selective_redo_merges_raw_inputs_and_preserves_handwriting(self): from copienator.review_snapshot import stage, load with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) seed(root) output = root / 'Bnot/Copie01' data = {l: {'pdf_path': root / 'Copies/Copie01' / f'{l}.pdf', 'result': {'score': 3, 'feedback': []}, 'coordinates': [0, 0]} for l in ('Ex 1', 'Ex 2')} ink = Image.new('RGBA', (100, 120), (255, 0, 0, 255)) bnote = {'images': [{'label':'Ex 1', 'header_height':20}, {'label':'Ex 2','header_height':10}]} with staged_files(output) as staging: stage(output, staging, data, {'Ex 1': {'img': ink}}, engine='standard', bnote=bnote) data['Ex 2']['result']['score'] = 1 with staged_files(output) as staging: stage(output, staging, data, {}, engine='grouped', selected={'Ex 2'}, force_filtered={'Ex 1'}) saved, restored, notes = load(root, output) self.assertEqual(restored['Ex 1']['result']['score'], 3) self.assertEqual(restored['Ex 2']['result']['score'], 1) self.assertEqual(notes['Ex 1']['img'].getpixel((0,0)), (255,0,0,255)) self.assertEqual(notes['Ex 1']['old_header_h'], 20) self.assertIn('Ex 1', saved['force_filtered']) self.assertEqual(restored['Ex 1']['pdf_path'], root / 'Copies/Copie01/Ex 1.pdf') def test_cached_regeneration_does_not_accumulate_custom_remarks(self): from copienator.review_snapshot import stage, load from copienator.commands import regenerate_reviewed from copienator.commands import annotating from copienator.custom_remarks import set_remark for mode in ('Bnot', 'BGnot'): with self.subTest(mode=mode), tempfile.TemporaryDirectory() as temporary: root = Path(temporary) seed(root, mode) output = root / mode / 'Copie01' pdf = root / 'Copies/Copie01/Ex 1.pdf' pdf.write_bytes(b'placeholder') data = {'Ex 1': {'pdf_path': pdf, 'result': {'score': 3, 'feedback': []}, 'coordinates': [0,0]}} notes = {'Ex 1': {'img': None, 'old_header_h': 20}} if mode == 'BGnot' else {} with staged_files(output) as staging: stage(output, staging, data, notes, engine='standard' if mode == 'Bnot' else 'grouped', bnote={'images':[{'label':'Ex 1','header_height':20}]}) set_remark(root, '01', 'Ex 1', 'Une seule remarque') with patch.object(annotating, 'make_base_image', return_value=(Image.new('RGB',(100,100),'white'),0,0)), \ patch.object(annotating, 'compose_label_image', return_value=(Image.new('RGB',(100,120),'white'),20)) as compose, \ patch('copienator.commands.reading_grouped_annotations.get_extra_pdfs_as_images', return_value=[]): for _ in range(2): self.assertEqual(regenerate_reviewed.run(EvaluationWorkspace(root), mode), 0) feedback = compose.call_args.args[2]['feedback'] self.assertEqual(len(feedback), 1) self.assertEqual(feedback[0]['text'], 'Une seule remarque') _, raw, _ = load(root, output) self.assertEqual(raw['Ex 1']['result']['feedback'], []) @unittest.skipUnless(os.environ.get('DISPLAY'), 'Tk requires a display') class WebGuiTests(unittest.TestCase): def test_web_button_requires_read_outputs_and_remains_enabled_when_stale(self): from copienator_gui.app import CopienatorApp with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) (root / 'Copies').mkdir() (root / 'labels').write_text('Ex 1\n') app = CopienatorApp(Path.cwd(), False, root) self.addCleanup(app.destroy) def state(): app._update_controls() return str(app.finalisation_web_button.cget('state')) self.assertEqual(state(), 'disabled') output = root / 'BGnot/Copie01' output.mkdir(parents=True) atomic_write_json(output/'bnote.json', {'images': []}) self.assertEqual(state(), 'disabled') seed(root, 'BGnot') self.assertEqual(state(), 'normal') app.state_store.update_step('read_annotations', status='stale') self.assertEqual(state(), 'normal') app.active_step_id = 'read_annotations' self.assertEqual(state(), 'disabled') app.active_step_id = None self.assertEqual(state(), 'normal') app.state_store.evaluation = None self.assertEqual(state(), 'disabled') def test_modal_lock_and_return_to_existing_workflow(self): from copienator_gui.app import CopienatorApp from copienator.finalisation_server import WebSession import tkinter as tk with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) seed(root) app = CopienatorApp(Path.cwd(), False, root) self.addCleanup(app.destroy) service = Finalisation(root, 'Bnot', 'token') class FakeSession: url = 'http://127.0.0.1:12345/' alive = True def request_stop(self): self.alive = False session = FakeSession() session.service = service def descendants(widget): for child in widget.winfo_children(): yield child yield from descendants(child) def button(text): return next(w for w in descendants(app) if w.winfo_class() == 'TButton' and w.cget('text') == text) with patch('copienator.finalisation_server.WebSession', return_value=session), patch('webbrowser.open'): app._open_finalisation_web() app.update() self.assertIsInstance(app.grab_current(), tk.Toplevel) button('Ouvrir').invoke() self.assertIs(app.web_session, session) self.assertEqual(str(button('Ouvrir').cget('state')), 'disabled') service.save({'kind':'score','copy':'01','label':'Ex 1','value':'1','revision':service.revision()}) with patch.object(app.runner, 'start') as start: app._run_current_step() start.assert_not_called() button('Arrêter le serveur et reprendre').invoke() app.after(450, app.quit) app.mainloop() self.assertIsNone(app.web_session) self.assertIsNone(app.grab_current()) self.assertEqual(app.state_store.step('read_annotations')['status'], 'stale') class IncrementalReviewedTests(unittest.TestCase): def test_only_changed_copies_render_and_missing_outputs_are_retried(self): from copienator.commands import regenerate_reviewed, annotating from copienator.review_snapshot import stage from copienator.custom_remarks import set_remark import shutil with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) seed(root, 'BGnot') pdf = root / 'Copies/Copie01/Ex 1.pdf' pdf.write_bytes(b'answer') data = {'Ex 1': {'pdf_path':pdf, 'result':{'score':3,'feedback':[]},'coordinates':[0,0]}} output = root / 'BGnot/Copie01' with staged_files(output) as staging: stage(output, staging, data, {}, engine='grouped') shutil.copytree(output, root / 'BGnot/Copie02') workspace = EvaluationWorkspace(root) with patch.object(annotating, 'make_base_image', return_value=(Image.new('RGB',(100,100),'white'),0,0)), \ patch.object(annotating, 'compose_label_image', return_value=(Image.new('RGB',(100,120),'white'),20)) as compose, \ patch('copienator.commands.reading_grouped_annotations.get_extra_pdfs_as_images', return_value=[]): def run(count): compose.reset_mock() self.assertEqual(regenerate_reviewed.run(workspace,'BGnot',incremental=True), 0) self.assertEqual(compose.call_count,count) run(2) run(0) set_score(output/'score.json','Ex 1','2') run(1) run(0) set_remark(root,'01','Ex 1','Justifier.') run(1) set_remark(root,'01','Ex 1','') run(1) (output/'Ex 1.jpg').unlink() run(1) (root/'Text2').mkdir() (root/'Text2/Ex 1.pdf').write_bytes(b'changed question') run(2) run(0) self.assertEqual(regenerate_reviewed.run(workspace,'BGnot'),0) self.assertEqual(compose.call_count,2) def test_incremental_transfers_apply_changes_and_undo_without_repeating(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) seed(root) workspace = EvaluationWorkspace(root) from copienator.annotation_modifications import rebuild with patch.object(modify_annotations, 'rebuild', wraps=rebuild) as render: def run(text, count): (root / DRAFT).write_text('### Source: Bnot\n' + text) render.reset_mock() self.assertEqual(modify_annotations.run(workspace, annotation_dir='Bnot', incremental=True), 0) self.assertEqual(render.call_count, count) run('Copie01 Ex 1 c{40}1> Ex 2|\n', 1) run('Copie01 Ex 1 c{40}1> Ex 2|\n', 0) run('Copie01 Ex 1 c{50}1> Ex 2|\n', 1) run('', 1) run('', 0) self.assertEqual(Image.open(root/'Bnot/Copie01/Ex 1.jpg').height,120) @patch('copienator.commands.giving_names.configuration.RETURN_ANSWERS_ENABLED', False) def test_return_publication_skips_unchanged_and_repairs_missing_files(self): import shutil with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) seed(root) shutil.copytree(root/'Bnot/Copie01',root/'Bnot/Copie02') atomic_write_json(root/'Copies/Copie02.json',{'name':'Bob'}) atomic_write_json(root/'Copies/Copie01.json',{'name':'Alice'}) workspace = EvaluationWorkspace(root) from copienator.return_answers import publish_answer_returns with patch.object(giving_names,'publish_answer_returns',wraps=publish_answer_returns) as publish: def run(count): publish.reset_mock() self.assertEqual(giving_names.prepare_named_returns(workspace,'Bnot',incremental=True),0) self.assertEqual(publish.call_count,count) run(2) run(0) set_score(root/'Bnot/Copie01/score.json','Ex 1','1') run(1) run(0) (root/'A Rendre/Bob (02)/info.json').unlink() run(1) self.assertTrue((root/'A Rendre/Bob (02)/info.json').is_file())