import copy import json import tempfile import unittest from pathlib import Path from unittest.mock import Mock, patch from PIL import Image from copienator import EvaluationWorkspace, atomic_write_json from copienator.annotation_data import GroupCoordinates, _scaled_result from copienator.annotation_actions import apply_checkbox_actions from copienator.commands import annotating, correction from copienator.feedback_boxes import valid_feedback_box class FeedbackBoxTests(unittest.TestCase): def test_checkbox_for_promoted_feedback_deletes_the_correct_comment(self): feedback = [{"text": "Invalid local", "box_2d": [753, 680, 287, 946]}, {"text": "Existing global", "box_2d": None}] apply_checkbox_actions({"Ex 5": {"result": {"feedback": feedback}}}, [{"label": "Ex 5", "type": "del_global", "index": 0}], lambda _: None) self.assertTrue(feedback[0]["to_delete"]) self.assertNotIn("to_delete", feedback[1]) def test_bad_boxes_keep_their_comment_as_global_feedback_without_mutating_data(self): for box in ([753, 680, 287, 946], [10, 50, 20, 30], [10, 20, 10, 30], [1, 2, 3], [None, 0, 10, 20], [float("nan"), 0, 10, 20]): with self.subTest(box=box): result = {"score": 2, "feedback": [{"text": "Important comment", "box_2d": box}]} original_box = result["feedback"][0]["box_2d"] scaled = _scaled_result(result, GroupCoordinates(2415, 3297, 1655, 3297)) callback = Mock() render = Mock(return_value=Image.new("RGBA", (200, 40), "white")) with patch.object(annotating, "render_score_text", return_value=Image.new("RGBA", (200, 40))): image, _ = annotating.compose_label_image(Image.new("RGBA", (800, 100)), "Ex 5", scaled, 2415, render_fn=render, draw_callback=callback) self.assertIsNotNone(image) render.assert_called_once_with("Important comment", unittest.mock.ANY) self.assertFalse(any(call.args[0] == "local_rect" for call in callback.call_args_list)) self.assertIs(result["feedback"][0]["box_2d"], original_box) def test_valid_boxes_remain_local(self): result = {"feedback": [{"text": "Local", "box_2d": [10, 20, 30, 40]}]} before = copy.deepcopy(result) callback = Mock() with patch.object(annotating, "render_score_text", return_value=Image.new("RGBA", (200, 40))): annotating.compose_label_image(Image.new("RGBA", (800, 100)), "Ex 5", result, 0, render_fn=Mock(return_value=Image.new("RGBA", (200, 40))), draw_callback=callback) self.assertTrue(any(call.args[0] == "local_rect" for call in callback.call_args_list)) self.assertEqual(result, before) self.assertTrue(valid_feedback_box([10, 20, 30, 40])) def test_invalid_auxiliary_response_loses_only_its_rectangle(self): returned = [{"text": "Keep this", "box_2d": [753, 680, 287, 946]}] with patch.object(correction.prompting, "request_for_box_correction", return_value=([], {})), patch.object( correction, "call_gemini_with_retries", return_value=json.dumps(returned) ): feedback = correction.correct_boxes_with_gemini("26", "Ex 5", Path("unused.pdf"), [], 0, 1000, 1, 1000) self.assertEqual(feedback, [{"text": "Keep this", "box_2d": None}]) def test_correction_requests_repair_for_inverted_boxes_and_falls_back_without_losing_comments(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) group = root / "Par label" / "Ex 5" / "Group_1.jpg" group.parent.mkdir(parents=True) group.touch() atomic_write_json(group.with_suffix(".json"), [["26", 0, 1000, 1, "Ex 5"]]) args = correction.build_parser().parse_args([str(root)]) correction.configure_runtime(EvaluationWorkspace(root), [(str(group), "Ex 5")], args, api_client=Mock()) response = [{"id": "26", "result": {"score": 2, "error": "", "feedback": [ {"text": "First", "box_2d": [753, 680, 287, 946]}, {"text": "Second", "box_2d": [100, 900, 200, 100]}]}}] with patch.object(correction.prompting, "generate_request", return_value=([], {})), patch.object( correction, "correct_boxes_with_gemini", side_effect=RuntimeError("repair failed") ) as repair: correction.process_single_task((str(group), "Ex 5"), json.dumps(response)) repair.assert_called_once() feedback = correction.results["Ex 5"][0][0]["result"]["feedback"] self.assertEqual([f["text"] for f in feedback], ["First", "Second"]) self.assertTrue(all(f["box_2d"] is None for f in feedback))