Keep empty answers in the final individual outputs.

This commit is contained in:
sebastien committed 2026-10-13 10:15:57 +02:00
1 parent b1f1e0bc86
commit e7094d850e
8 files changed
+160 -33

No files matched your search

+11 -5
View File
@@ -155,7 +155,7 @@ def output_removals(directory: Path) -> tuple[str, ...]:
info = read_json(directory / "info.json") if (directory / "info.json").exists() else {} info = read_json(directory / "info.json") if (directory / "info.json").exists() else {}
return ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json", return ("Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
*(f"{label}.jpg" for label, entry in info.items() *(f"{label}.jpg" for label, entry in info.items()
if entry.get("not_empty") and Path(label).name == label)) if Path(label).name == label))
def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict, def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict,
@@ -169,7 +169,15 @@ def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict,
headers, bodies, changed = edited_bodies(directory, meta, instructions) headers, bodies, changed = edited_bodies(directory, meta, instructions)
info = deepcopy(meta["info"]) info = deepcopy(meta["info"])
filtered = set(meta["filtered"]) | changed for label, entry in info.items():
# Retained empty scans are images, not evidence of a non-empty answer.
entry["not_empty"] = label in bodies and (entry["not_empty"] or label in changed)
if label in changed:
entry["present"] = label in bodies
entry["score"] = scores.get(label, entry["score"])
filtered = (set(meta["filtered"]) | changed) & {
label for label, entry in info.items() if entry["not_empty"]
}
all_labels = utils.read_all_labels(root) all_labels = utils.read_all_labels(root)
images, groups = [], [] images, groups = [], []
seen_contexts = set() seen_contexts = set()
@@ -187,15 +195,13 @@ def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict,
fill="black", font_size=30) fill="black", font_size=30)
image = stack([header, body]) image = stack([header, body])
image.save(staging / f"{label}.jpg") image.save(staging / f"{label}.jpg")
if info[label]["not_empty"]:
images.append(image) images.append(image)
if label in filtered: if label in filtered:
extras = get_extra_pdfs_as_images(root, label, annotating, all_labels, extras = get_extra_pdfs_as_images(root, label, annotating, all_labels,
seen_contexts=seen_contexts) if mode == "BGnot" else [] seen_contexts=seen_contexts) if mode == "BGnot" else []
groups.append([*extras, image]) groups.append([*extras, image])
for label, entry in info.items(): for label, entry in info.items():
entry["not_empty"] = label in bodies
if label in changed and label in bodies:
entry["present"] = True
entry["touched"] = mode == "BGnot" and label in bodies and label in filtered entry["touched"] = mode == "BGnot" and label in bodies and label in filtered
entry["score"] = scores.get(label, entry["score"]) entry["score"] = scores.get(label, entry["score"])
atomic_write_json(staging / "info.json", info) atomic_write_json(staging / "info.json", info)
+1 -2
View File
@@ -550,8 +550,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
canvas.paste(image, (0, current_y)) canvas.paste(image, (0, current_y))
current_y += image.height current_y += image.height
canvas.save(staging / "Concat.jpg") canvas.save(staging / "Concat.jpg")
elif labels_data: # An all-empty copy still has valid individual return images.
problems = True
return "partial" if problems else "success" return "partial" if problems else "success"
+24 -9
View File
@@ -157,10 +157,14 @@ def apply_actions_and_regenerate(
"""Apply annotations and atomically merge the regenerated student files.""" """Apply annotations and atomically merge the regenerated student files."""
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}" output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
bnote_path = output_dir / "bnote.json" bnote_path = output_dir / "bnote.json"
if not bnote_path.is_file(): only_empty = bool(data[student_id]) and all(
content["result"].get("error") == "empty-answer"
for content in data[student_id].values()
)
if not bnote_path.is_file() and not only_empty:
print(f" Missing {bnote_path}") print(f" Missing {bnote_path}")
return ExitCode.PARTIAL return ExitCode.PARTIAL
bnote_data = read_json(bnote_path) bnote_data = read_json(bnote_path) if bnote_path.is_file() else {"images": []}
if not isinstance(bnote_data, dict): if not isinstance(bnote_data, dict):
raise TypeError(f"Expected a JSON object in {bnote_path}") raise TypeError(f"Expected a JSON object in {bnote_path}")
@@ -180,7 +184,14 @@ def apply_actions_and_regenerate(
filtered: list[Image.Image] = [] filtered: list[Image.Image] = []
incomplete = False incomplete = False
for image_info in bnote_data.get("images", []): review_images = list(bnote_data.get("images", []))
reviewed_labels = {str(item.get("label", "")) for item in review_images if isinstance(item, dict)}
# Empty answers are omitted from the review sheet, but still need return images.
review_images.extend(
{"label": label} for label, content in labels_data.items()
if label not in reviewed_labels and content["result"].get("error") == "empty-answer"
)
for image_info in review_images:
if not isinstance(image_info, dict): if not isinstance(image_info, dict):
incomplete = True incomplete = True
continue continue
@@ -191,11 +202,10 @@ def apply_actions_and_regenerate(
content = labels_data[label] content = labels_data[label]
result = content["result"] result = content["result"]
scores[label] = str(result.get("score", 0)) scores[label] = str(result.get("score", 0))
if result.get("error") == "empty-answer": is_empty = result.get("error") == "empty-answer"
continue
sub_note = None sub_note = None
if notes_layer is not None: if notes_layer is not None and "hmin" in image_info:
hmin = int(image_info.get("hmin", 0)) hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0)) hmax = int(image_info.get("hmax", 0))
sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax)) sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax))
@@ -213,6 +223,7 @@ def apply_actions_and_regenerate(
result, result,
content["coordinates"][0], content["coordinates"][0],
with_error=False, with_error=False,
with_empty=True,
) )
if final_image is None: if final_image is None:
incomplete = True incomplete = True
@@ -230,6 +241,8 @@ def apply_actions_and_regenerate(
dirty_images[label] = final_image dirty_images[label] = final_image
header_heights[label] = new_header_height header_heights[label] = new_header_height
if is_empty:
continue
answer_labels.append(label) answer_labels.append(label)
concatenated.append(final_image) concatenated.append(final_image)
if float(scores[label]) != 4.0 or result.get("feedback", []): if float(scores[label]) != 4.0 or result.get("feedback", []):
@@ -302,13 +315,15 @@ def run(
status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS status = ExitCode.PARTIAL if loaded.warnings else ExitCode.SUCCESS
for student_id in sorted(loaded.data, key=utils.natural_key): for student_id in sorted(loaded.data, key=utils.natural_key):
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}" output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
if not output_dir.is_dir(): only_empty = all(content["result"].get("error") == "empty-answer"
for content in loaded.data[student_id].values())
if not output_dir.is_dir() and not only_empty:
print(f"Warning: missing annotation directory {output_dir}") print(f"Warning: missing annotation directory {output_dir}")
status = ExitCode.PARTIAL status = ExitCode.PARTIAL
continue continue
print(f"Processing annotations for: {student_id}") print(f"Processing annotations for: {student_id}")
actions, notes = detect_checks_and_notes(output_dir) actions, notes = ([], None) if only_empty else detect_checks_and_notes(output_dir)
if notes is None and not actions and not update_score: if notes is None and not actions and not update_score and not only_empty:
print(" No readable annotation input found.") print(" No readable annotation input found.")
status = ExitCode.PARTIAL status = ExitCode.PARTIAL
continue continue
@@ -292,8 +292,7 @@ def apply_actions_and_regenerate_grouped(
result["score"] = old_scores[label] result["score"] = old_scores[label]
scores[label] = str(result.get("score", 0)) scores[label] = str(result.get("score", 0))
touched[label] = False touched[label] = False
if result.get("error") == "empty-answer": is_empty = result.get("error") == "empty-answer"
continue
saved_image = output_dir / f"{label}.jpg" saved_image = output_dir / f"{label}.jpg"
baseline_entry = (previous_baseline or {}).get("images", {}).get(label) baseline_entry = (previous_baseline or {}).get("images", {}).get(label)
if selected_labels and label not in selected_labels and (saved_image.is_file() or baseline_entry): if selected_labels and label not in selected_labels and (saved_image.is_file() or baseline_entry):
@@ -319,6 +318,8 @@ def apply_actions_and_regenerate_grouped(
) )
dirty_images[label] = final_image dirty_images[label] = final_image
scores[label] = str(old_scores.get(label, scores[label])) scores[label] = str(old_scores.get(label, scores[label]))
if is_empty:
continue
concat_images.append(final_image) concat_images.append(final_image)
answer_labels.append(label) answer_labels.append(label)
filtered_labels.append(label) filtered_labels.append(label)
@@ -344,6 +345,7 @@ def apply_actions_and_regenerate_grouped(
result, result,
content["coordinates"][0], content["coordinates"][0],
with_error=False, with_error=False,
with_empty=True,
) )
if final_image is None: if final_image is None:
incomplete = True incomplete = True
@@ -368,6 +370,8 @@ def apply_actions_and_regenerate_grouped(
# Persist every final block, including unchanged answers, for returns. # Persist every final block, including unchanged answers, for returns.
dirty_images[label] = final_image dirty_images[label] = final_image
header_heights[label] = new_header_height header_heights[label] = new_header_height
if is_empty:
continue
answer_labels.append(label) answer_labels.append(label)
concat_images.append(final_image) concat_images.append(final_image)
+1 -1
View File
@@ -84,7 +84,7 @@ def publish_answer_returns(
raise ValueError(f"Expected a real answer directory: {answers_dir}") raise ValueError(f"Expected a real answer directory: {answers_dir}")
if configuration.RETURN_ANSWERS_ENABLED: if configuration.RETURN_ANSWERS_ENABLED:
options = load_return_answer_options(root) options = load_return_answer_options(root)
labels = [label for label, entry in info.items() if entry["present"] and entry["not_empty"]] labels = [label for label, entry in info.items() if entry["present"]]
# Import the rendering backend only when individual images are requested. # Import the rendering backend only when individual images are requested.
from copienator.commands.annotating import make_base_image from copienator.commands.annotating import make_base_image
+12 -9
View File
@@ -39,7 +39,7 @@ answer JPEGs.
| `Student Name.pdf` | `Concat_F.pdf` | Filtered, paginated correction with context, questions and solutions. | | `Student Name.pdf` | `Concat_F.pdf` | Filtered, paginated correction with context, questions and solutions. |
| `score.json` | `score.json` | Per-question scores, including questions omitted from the filtered PDF. | | `score.json` | `score.json` | Per-question scores, including questions omitted from the filtered PDF. |
| `info.json` | `info.json` | Answer presence, empty-answer classification, PDF membership and score. | | `info.json` | `info.json` | Answer presence, empty-answer classification, PDF membership and score. |
| `answers/*.jpg` | Final per-label JPEGs selected by `info.json` | One annotated non-empty answer, with optional supplementary material. | | `answers/*.jpg` | Final per-label JPEGs selected by `info.json` | One annotated answer, including empty scans, with optional supplementary material. |
## Enabling or disabling outputs ## Enabling or disabling outputs
@@ -69,9 +69,9 @@ and preserves return PDFs when present.
## Individual answer JPEGs ## Individual answer JPEGs
With `RETURN_ANSWERS_ENABLED = True`, `giving-names` generates an `answers/` With `RETURN_ANSWERS_ENABLED = True`, `giving-names` generates an `answers/`
subdirectory inside each student's return folder. It includes **every non-empty subdirectory inside each student's return folder. It includes **every present
compiled answer**, even a perfect answer omitted from the filtered PDF. Labels compiled answer**, even a perfect answer omitted from the filtered PDF. Scans
marked `empty-answer` and labels without an answer are excluded. Every JPEG marked `empty-answer` are included; labels without an answer are excluded. Every JPEG
contains the final annotated student answer, including retained feedback and contains the final annotated student answer, including retained feedback and
extracted handwriting. extracted handwriting.
@@ -92,7 +92,7 @@ Filenames use natural label order, a three-digit minimum sequence number, and a
sanitized label (`001 - Ex 1.jpg`). Numbering prevents filename collisions when sanitized label (`001 - Ex 1.jpg`). Numbering prevents filename collisions when
labels differ only by characters forbidden in filenames. JSON keys retain exact labels differ only by characters forbidden in filenames. JSON keys retain exact
labels. The managed `answers/` directory is replaced on successful generation, labels. The managed `answers/` directory is replaced on successful generation,
so removed/empty answers do not leave stale images; failures preserve the previous so removed answers do not leave stale images; failures preserve the previous
directory. Disabling the option clears this directory on the next `giving-names` directory. Disabling the option clears this directory on the next `giving-names`
run. Separate storage keeps these images out of the personal final-mark stamping run. Separate storage keeps these images out of the personal final-mark stamping
step, which reads only JPEGs directly inside the student's folder. step, which reads only JPEGs directly inside the student's folder.
@@ -116,11 +116,13 @@ Every label in `score.json` has an object containing exactly four fields:
``` ```
- `present`: an answer entry exists for this student and label in the compilation - `present`: an answer entry exists for this student and label in the compilation
data. A supplied answer judged empty still has `present: true`. data. A supplied answer judged empty still has `present: true`. A body removed by
manual annotation edits has `present: false`.
- `not_empty`: the answer was not marked `empty-answer` and was successfully - `not_empty`: the answer was not marked `empty-answer` and was successfully
compiled. Absent answers have `not_empty: false`. When individual export is compiled. Absent answers have `not_empty: false`. When individual export is
enabled, a JPEG is generated if and only if both `present` and `not_empty` are enabled, every `present` answer gets a JPEG, including those with `not_empty: false`.
true. These fields describe the answer regardless of export settings. Image retention alone does not change the empty classification. These fields
describe the answer regardless of export settings.
- `touched`: the answer appears in the compiled filtered `Concat_F.pdf`, using - `touched`: the answer appears in the compiled filtered `Concat_F.pdf`, using
the actual selection including handwriting and selective redo preservation. the actual selection including handwriting and selective redo preservation.
It is not a flag for human edits. Empty and absent answers have `false`. It is not a flag for human edits. Empty and absent answers have `false`.
@@ -151,7 +153,8 @@ The result is one RGB image of variable height, with no page breaks. It contains
all successfully compiled answer blocks, including answers scored 4 with no all successfully compiled answer blocks, including answers scored 4 with no
remaining feedback. “Full” refers to those answer blocks, not the original scan remaining feedback. “Full” refers to those answer blocks, not the original scan
pages or every question in the statement. Missing/unrenderable answers cannot pages or every question in the statement. Missing/unrenderable answers cannot
be included, and the renderer normally suppresses `empty-answer` results. be included. Empty-answer scans are retained individually but excluded from
the combined and filtered documents.
The grouped compiler refuses to publish a new set when compilation is incomplete. The grouped compiler refuses to publish a new set when compilation is incomplete.
The JPEG does not prepend the question, context or model solution PDFs. The JPEG does not prepend the question, context or model solution PDFs.
+23
View File
@@ -115,6 +115,29 @@ class ModificationTests(unittest.TestCase):
self.assertFalse((self.output / "Empty.jpg").exists()) self.assertFalse((self.output / "Empty.jpg").exists())
self.assertFalse(read_json(self.output / "info.json")["Empty"]["not_empty"]) self.assertFalse(read_json(self.output / "info.json")["Empty"]["not_empty"])
def test_retained_empty_scan_stays_empty_after_unrelated_edits_and_can_be_replaced(self):
self.images["Empty"] = Image.new("RGB", (100, 60), "white")
self.heights["Empty"] = 10
self.info["Empty"]["present"] = True
self.seed()
self.apply("Copie43 Ex 1 -> Ex 2|")
info = read_json(self.output / "info.json")
self.assertTrue((self.output / "Empty.jpg").is_file())
self.assertEqual(info["Empty"], self.info["Empty"])
with Image.open(self.output / "Concat.jpg") as image:
self.assertEqual(image.height, 120 + 180 + 40)
self.apply("Copie43 Ex 1 -> Empty")
self.assertTrue(read_json(self.output / "info.json")["Empty"]["not_empty"])
self.apply()
self.assertEqual(read_json(self.output / "info.json")["Empty"], self.info["Empty"])
def test_removed_body_is_absent_and_not_exported_as_an_empty_scan(self):
self.apply("Copie43 Ex 1 x> Ex 2")
info = read_json(self.output / "info.json")["Ex 1"]
self.assertFalse(info["present"])
self.assertFalse(info["not_empty"])
self.assertFalse((self.output / "Ex 1.jpg").exists())
def test_invalid_file_does_not_apply_valid_first_instruction(self): def test_invalid_file_does_not_apply_valid_first_instruction(self):
before = {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()} before = {p.name: p.read_bytes() for p in self.output.iterdir() if p.is_file()}
with self.assertRaises(CliError): with self.assertRaises(CliError):
+82 -5
View File
@@ -57,8 +57,8 @@ class AnswerReturnTests(unittest.TestCase):
), patch.object(annotating, "make_base_image", side_effect=self.supplement): ), patch.object(annotating, "make_base_image", side_effect=self.supplement):
publish_answer_returns(self.root, self.source, self.destination) publish_answer_returns(self.root, self.source, self.destination)
files = sorted((self.destination / "answers").glob("*.jpg")) files = sorted((self.destination / "answers").glob("*.jpg"))
self.assertEqual([p.name for p in files], ["Ex 1.jpg", "Ex 2.jpg"]) self.assertEqual([p.name for p in files], ["Empty.jpg", "Ex 1.jpg", "Ex 2.jpg"])
with Image.open(files[0]) as image: with Image.open(self.destination / "answers" / "Ex 1.jpg") as image:
self.assertEqual(image.size, (100, 30 + 20 * sum((context, question, solution)))) self.assertEqual(image.size, (100, 30 + 20 * sum((context, question, solution))))
colors = [] colors = []
if context: if context:
@@ -212,7 +212,7 @@ class AnswerReturnTests(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
sorted(path.name for path in answers.iterdir()), sorted(path.name for path in answers.iterdir()),
["Ex 1.jpg", "Ex 2.jpg"], ["Empty.jpg", "Ex 1.jpg", "Ex 2.jpg"],
) )
@@ -304,7 +304,7 @@ class CompiledMembershipTests(unittest.TestCase):
self.assertEqual(info["Empty"], {"present": True, "not_empty": False, "touched": False, "score": "0"}) self.assertEqual(info["Empty"], {"present": True, "not_empty": False, "touched": False, "score": "0"})
self.assertEqual(info["Absent"], {"present": False, "not_empty": False, "touched": False, "score": ""}) self.assertEqual(info["Absent"], {"present": False, "not_empty": False, "touched": False, "score": ""})
self.assertEqual(info["Perfect"], {"present": True, "not_empty": True, "touched": False, "score": "4"}) self.assertEqual(info["Perfect"], {"present": True, "not_empty": True, "touched": False, "score": "4"})
self.assertNotIn("Empty", rendered) self.assertIn("Empty", rendered)
for label in rendered: for label in rendered:
self.assertTrue((output / f"{label}.jpg").is_file()) self.assertTrue((output / f"{label}.jpg").is_file())
# Selective redo keeps unselected saved answers in the PDF, # Selective redo keeps unselected saved answers in the PDF,
@@ -316,6 +316,76 @@ class CompiledMembershipTests(unittest.TestCase):
self.assertTrue(read_json(output / "info.json")["Perfect"]["touched"]) self.assertTrue(read_json(output / "info.json")["Perfect"]["touched"])
self.assertFalse(read_json(output / "info.json")["Empty"]["not_empty"]) self.assertFalse(read_json(output / "info.json")["Empty"]["not_empty"])
def test_real_empty_pdf_is_published_in_all_annotation_modes(self):
import fitz
from copienator.commands import reading_annotations as standard
for mode in ("Anot", "Bnot", "BGnot"):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
workspace = EvaluationWorkspace(root)
(root / "labels").write_text("Empty\nAbsent\n")
pdf = root / "answer.pdf"
with fitz.open() as document:
page = document.new_page(width=200, height=60)
page.insert_text((10, 30), "Copied question only")
document.save(pdf)
data = {"01": {"Empty": {"pdf_path": pdf, "coordinates": (0, 0),
"result": {"score": 0, "error": "empty-answer", "feedback": []}}}}
if mode == "Anot":
status = annotating.process_student("01", data["01"], root, ["Empty", "Absent"], True)
self.assertEqual(status, "success")
elif mode == "Bnot":
status = standard.apply_actions_and_regenerate(
workspace, data, "01", [], None, ["Empty", "Absent"])
self.assertEqual(status, 0)
else:
status, _ = reader.apply_actions_and_regenerate_grouped(
workspace, data, "01", [], {}, ["Empty", "Absent"])
self.assertEqual(status, 0)
output = root / mode / "Copie01"
destination = root / "return"
destination.mkdir()
with patch.multiple(configuration, RETURN_ANSWERS_ENABLED=True,
RETURN_ANSWERS_CONTEXT=False, RETURN_ANSWERS_QUESTION=False,
RETURN_ANSWERS_SOLUTION=False):
publish_answer_returns(root, output, destination)
self.assertEqual([p.name for p in (destination / "answers").iterdir()], ["Empty.jpg"])
with Image.open(destination / "answers" / "Empty.jpg") as image:
self.assertGreater(image.height, 0)
self.assertLess(image.convert("L").getextrema()[0], 100)
self.assertFalse(read_json(destination / "info.json")["Empty"]["not_empty"])
self.assertFalse((output / "Concat.jpg").exists())
self.assertFalse((output / "Concat_F.pdf").exists())
def test_bnot_recovers_empty_scans_missing_from_review_layout(self):
from copienator.commands import reading_annotations as standard
for only_empty in (False, True):
with self.subTest(only_empty=only_empty), tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory))
output = workspace.root / "Bnot" / "Copie01"
output.mkdir(parents=True)
answer = workspace.root / "answer.pdf"
answer.touch()
labels = {"Empty": {"pdf_path": answer, "coordinates": (0, 0),
"result": {"score": 0, "error": "empty-answer"}}}
if not only_empty:
labels["Full"] = {"pdf_path": answer, "coordinates": (0, 0),
"result": {"score": 4}}
atomic_write_json(output / "bnote.json", {"images": [
{"label": "Full", "hmin": 0, "hmax": 50, "header_height": 0}]})
with patch.object(annotating, "make_base_image", return_value=(None, 0, 0)), patch.object(
annotating, "compose_label_image", return_value=(Image.new("RGB", (100, 50)), 0)
) as render:
status = standard.apply_actions_and_regenerate(
workspace, {"01": labels}, "01", [], None, [*labels, "Absent"])
self.assertEqual(status, 0)
self.assertTrue(all(call.kwargs["with_empty"] for call in render.call_args_list))
self.assertTrue((output / "Empty.jpg").is_file())
self.assertFalse((output / "Absent.jpg").exists())
self.assertFalse(read_json(output / "info.json")["Empty"]["not_empty"])
self.assertEqual((output / "Concat.jpg").exists(), not only_empty)
self.assertFalse((output / "Concat_F.jpg").exists())
def test_all_empty_removes_stale_concat_and_records_false(self): def test_all_empty_removes_stale_concat_and_records_false(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
workspace = EvaluationWorkspace(Path(directory)) workspace = EvaluationWorkspace(Path(directory))
@@ -325,10 +395,17 @@ class CompiledMembershipTests(unittest.TestCase):
(output / name).write_bytes(b"stale") (output / name).write_bytes(b"stale")
atomic_write_json(output / "touched.json", {"Empty": True}) atomic_write_json(output / "touched.json", {"Empty": True})
atomic_write_json(output / "answer_labels.json", ["Empty"]) atomic_write_json(output / "answer_labels.json", ["Empty"])
answer = workspace.root / "answer.pdf"
answer.touch()
with patch.object(annotating, "make_base_image", return_value=(Image.new("RGB", (100, 50)), 0, 0)), patch.object(
annotating, "compose_label_image", return_value=(Image.new("RGB", (100, 50)), 0)
):
status, _ = reader.apply_actions_and_regenerate_grouped( status, _ = reader.apply_actions_and_regenerate_grouped(
workspace, {"01": {"Empty": {"result": {"score": 0, "error": "empty-answer"}}}}, workspace, {"01": {"Empty": {"result": {"score": 0, "error": "empty-answer"},
"pdf_path": answer, "coordinates": (0, 0)}}},
"01", [], {}, ["Empty"] "01", [], {}, ["Empty"]
) )
self.assertTrue((output / "Empty.jpg").is_file())
self.assertEqual(status, 0) self.assertEqual(status, 0)
self.assertEqual(read_json(output / "info.json"), { self.assertEqual(read_json(output / "info.json"), {
"Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"} "Empty": {"present": True, "not_empty": False, "touched": False, "score": "0"}