tts-voice-creator-clone-and.../tests/test_magazine.py
mARTin-B78 813e06c914 feat: complete magazine P1 (images, play-all, karaoke, driving mode, sidebar exclusion) + OCR engines page + second OCR engine
Magazine P1:
- Images shown automatically during playback (assign_images_to_articles
  links each figure to the article whose column it falls in; the client
  shows the last image reached in reading order as playback advances).
- "Play all articles" queues every approved article and advances
  automatically at the end of each rather than stopping.
- Word-level highlighting, explicitly documented as an ESTIMATE (no
  forced-alignment/timestamp source exists): each word's share of the
  sentence's real measured audio duration is proportional to its
  character length. A checkbox turns it off; sentence highlighting
  (measured, not estimated) is unaffected.
- Driving mode: full-screen overlay, two oversized controls, reuses
  whatever is already playing/queued.
- Sidebars/pull-out boxes excluded from narration automatically
  (mark_sidebars: an anomalously narrow column vs. the page's regular
  grid). Fixed a real defect found while building this: a full-width
  headline was left in the column-width analysis and could bridge two
  genuinely separate columns into one, hiding a real sidebar column
  entirely -- caught by a failing test before the fix.

Second OCR engine + fallback ladder:
- KDL Frontier Parser nano wired in via vision_ocr_page_chain (ordered
  engine list, first usable result wins, tesseract last resort).
  Confirmed live: ~3x faster than Baidu (~12s vs ~40s/page) but returns
  plain text with NO coordinate markers, unlike Baidu's labeled regions.
  _whole_page_region treats the whole response as one ambiguous
  full-page block (gated by ocr_quality_score) rather than discarding
  usable text just because it lacks layout structure.

OCR engines page + a real UI bug:
- The OCR & Document Parsing page had no .s-engines-page[data-page="ocr"]
  markup at all -- renderLocalContainers already supported an OCR grid
  generically, but $('dc-grid-ocr') was always null, so the card grid
  silently never rendered. Added an Active OCR Engine panel mirroring
  Active Language Model exactly, plus the Local grid -- both configured
  engines now appear as ready-made cards, no "Add Custom" dialog needed.
- Fixed the audiobook pipeline's "Previous/Next" nav bar bleeding into
  every .section-head in the document (confirmed live on the Engines
  page: a "Cast Audiobook" button and "STEP 1/8" hint that made no sense
  there). Changed from a blocklist (naming each unrelated section as
  noticed) to an allowlist of the pipeline's three actual destinations.

Verified live end-to-end: play-all queue building and manual advance,
word-span markup, driving-mode toggle, OCR page showing both engines as
local cards, zero stray nav bars on the Engines page, no console errors.

20 new backend tests (156 total in the magazine module).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:39:34 +02:00

1024 lines
48 KiB
Python

"""Magazine layout analysis regressions.
These cover the pure geometry/classification logic rather than PyMuPDF I/O:
reading order across columns and article boundary detection are what decide
whether an article is narrated from its true beginning to its true end, so
they are the parts worth locking down.
"""
from __future__ import annotations
import os
import tempfile
import pathlib
import unittest
from unittest import mock
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-magazine-test-config-"))
from core.magazine import (
ocr_page,
cluster_running_furniture,
looks_like_real_title,
Article,
BLOCK_AD,
BLOCK_BODY,
BLOCK_BYLINE,
BLOCK_CAPTION,
BLOCK_DECK,
BLOCK_FURNITURE,
BLOCK_PULLQUOTE,
BLOCK_SIDEBAR,
BLOCK_TITLE,
Block,
article_text,
assign_images_to_articles,
assign_reading_order,
body_font_size,
build_articles,
build_vision_blocks,
classify_blocks,
detect_columns,
estimate_font_size_from_area,
estimate_listen_seconds,
find_continuation,
find_repeated_lines,
image_reading_position,
strip_continuation_markers,
group_articles,
looks_like_byline,
mark_advert_pages,
mark_pullquotes,
mark_sidebars,
ocr_quality_score,
parse_contents,
parse_vision_ocr_regions,
score_contents_page,
vision_ocr_page,
vision_ocr_page_chain,
)
PAGE_W, PAGE_H = 595.0, 842.0 # A4 in points
def block(bid, page, x, y, w, h, text, size=10.0, btype=BLOCK_BODY):
return Block(id=bid, page=page, bbox=[x, y, w, h], type=btype,
text=text, font_size=size)
class ColumnGeometryTests(unittest.TestCase):
def test_two_columns_detected_from_the_gutter(self):
blocks = [
block("a", 1, 50, 100, 200, 300, "left copy"),
block("b", 1, 320, 100, 200, 300, "right copy"),
]
cols = detect_columns(blocks, PAGE_W)
self.assertEqual(len(cols), 2)
self.assertLess(cols[0][1], cols[1][0])
def test_single_column_when_no_gutter(self):
blocks = [
block("a", 1, 50, 100, 480, 100, "wide copy"),
block("b", 1, 50, 220, 480, 100, "more wide copy"),
]
self.assertEqual(len(detect_columns(blocks, PAGE_W)), 1)
def test_reading_order_finishes_a_column_before_starting_the_next(self):
"""The failure this prevents: interleaving columns by y, which splices
the left column's sentence into the right column's."""
blocks = [
block("l1", 1, 50, 100, 200, 80, "left top"),
block("r1", 1, 320, 100, 200, 80, "right top"),
block("l2", 1, 50, 200, 200, 80, "left bottom"),
block("r2", 1, 320, 200, 200, 80, "right bottom"),
]
order = [b.id for b in assign_reading_order(blocks, PAGE_W)]
self.assertEqual(order, ["l1", "l2", "r1", "r2"])
def test_full_width_headline_precedes_both_columns(self):
blocks = [
block("l1", 1, 50, 200, 200, 80, "left copy"),
block("r1", 1, 320, 200, 200, 80, "right copy"),
block("head", 1, 50, 80, 470, 60, "The Future of Batteries", size=24.0),
]
order = [b.id for b in assign_reading_order(blocks, PAGE_W)]
self.assertEqual(order[0], "head")
class ClassificationTests(unittest.TestCase):
def test_body_size_is_not_hijacked_by_display_type(self):
blocks = [
block("t", 1, 50, 50, 400, 60, "HUGE HEADLINE", size=40.0),
block("b1", 1, 50, 150, 200, 200, "x" * 400, size=10.0),
block("b2", 1, 320, 150, 200, 200, "y" * 400, size=10.0),
]
self.assertAlmostEqual(body_font_size(blocks), 10.0, places=1)
def test_headline_byline_and_body_are_separated(self):
blocks = [
block("t", 1, 50, 60, 470, 50, "The Future of Batteries", size=26.0),
block("by", 1, 50, 120, 200, 14, "By Jane Smith", size=10.0),
block("b", 1, 50, 150, 200, 300, "Battery technology " * 40, size=10.0),
]
classify_blocks(blocks, PAGE_H, PAGE_W)
kinds = {b.id: b.type for b in blocks}
self.assertEqual(kinds["t"], BLOCK_TITLE)
self.assertEqual(kinds["by"], BLOCK_BYLINE)
self.assertEqual(kinds["b"], BLOCK_BODY)
def test_running_head_and_folio_become_furniture(self):
blocks = [
block("head", 1, 50, 20, 200, 12, "SCIENTIFIC AMERICAN", size=8.0),
block("folio", 1, 520, 820, 20, 12, "18", size=8.0),
block("b", 1, 50, 150, 200, 300, "Body copy " * 40, size=10.0),
]
classify_blocks(blocks, PAGE_H, PAGE_W,
repeated={"SCIENTIFIC AMERICAN"})
kinds = {b.id: b.type for b in blocks}
self.assertEqual(kinds["head"], BLOCK_FURNITURE)
self.assertEqual(kinds["folio"], BLOCK_FURNITURE)
self.assertEqual(kinds["b"], BLOCK_BODY)
def test_small_text_next_to_a_figure_is_a_caption(self):
"""A photo page has no body copy of its own, so the baseline must come
from the document -- otherwise the caption becomes its own reference."""
blocks = [block("cap", 1, 50, 420, 200, 20, "A lithium cell.", size=7.5)]
classify_blocks(blocks, PAGE_H, PAGE_W,
image_boxes=[[50, 200, 200, 210]], base_size=10.0)
self.assertEqual(blocks[0].type, BLOCK_CAPTION)
def test_small_text_away_from_any_figure_is_not_a_caption(self):
blocks = [block("fn", 1, 50, 420, 200, 20, "See note 4.", size=7.5)]
classify_blocks(blocks, PAGE_H, PAGE_W, image_boxes=[], base_size=10.0)
self.assertNotEqual(blocks[0].type, BLOCK_CAPTION)
def test_photo_page_caption_would_misclassify_on_a_per_page_baseline(self):
"""Guards the regression directly: no base_size means the lone caption
measures itself and reads as body text."""
blocks = [block("cap", 1, 50, 420, 200, 20, "A lithium cell.", size=7.5)]
classify_blocks(blocks, PAGE_H, PAGE_W,
image_boxes=[[50, 200, 200, 210]])
self.assertEqual(blocks[0].type, BLOCK_BODY)
def test_advertisement_marker_is_excluded(self):
blocks = [block("ad", 1, 50, 50, 200, 20, "ADVERTISEMENT", size=9.0)]
classify_blocks(blocks, PAGE_H, PAGE_W)
self.assertEqual(blocks[0].type, BLOCK_AD)
def test_an_advertisement_label_carries_its_whole_page(self):
"""The ad's own display type would otherwise read as a headline."""
blocks = [
block("m", 4, 50, 50, 200, 20, "ADVERTISEMENT", size=9.0),
block("big", 4, 60, 400, 400, 60, "BUY OUR THING", size=40.0),
block("folio", 4, 520, 820, 20, 12, "4", size=8.0),
]
classify_blocks(blocks, PAGE_H, PAGE_W, repeated=set(), base_size=10.0)
mark_advert_pages(blocks)
kinds = {b.id: b.type for b in blocks}
self.assertEqual(kinds["big"], BLOCK_AD)
self.assertEqual(kinds["folio"], BLOCK_FURNITURE)
def test_a_page_without_the_label_is_untouched(self):
blocks = [
block("t", 3, 50, 60, 470, 50, "The Future of Batteries", size=26.0),
block("b", 3, 50, 150, 200, 300, "Battery copy. " * 40, size=10.0),
]
classify_blocks(blocks, PAGE_H, PAGE_W, base_size=10.0)
mark_advert_pages(blocks)
self.assertEqual(blocks[0].type, BLOCK_TITLE)
def test_pullquote_repeating_body_copy_is_demoted(self):
"""Narrating a pull quote reads the same sentence twice."""
sentence = "Battery chemistry is entering an entirely new phase"
blocks = [
block("b", 1, 50, 150, 200, 300,
f"Some lead in. {sentence}. And more copy follows.", size=10.0),
block("pq", 1, 320, 300, 200, 60, f"“{sentence}”", size=14.0),
]
classify_blocks(blocks, PAGE_H, PAGE_W)
mark_pullquotes(blocks)
self.assertEqual(blocks[1].type, BLOCK_PULLQUOTE)
def test_deck_that_does_not_repeat_body_stays_a_deck(self):
blocks = [
block("b", 1, 50, 300, 200, 300, "Entirely different copy. " * 20, size=10.0),
block("dk", 1, 50, 120, 470, 40,
"Why the next decade belongs to solid state", size=14.0),
]
classify_blocks(blocks, PAGE_H, PAGE_W)
mark_pullquotes(blocks)
self.assertEqual(blocks[1].type, BLOCK_DECK)
def test_byline_rejects_a_sentence(self):
self.assertEqual(looks_like_byline("By the time the cell charges, it is hot.")[0], False)
self.assertEqual(looks_like_byline("By Jane Smith"), (True, "Jane Smith"))
self.assertEqual(looks_like_byline("Von Jan Müller"), (True, "Jan Müller"))
class FurnitureDetectionTests(unittest.TestCase):
def test_lines_repeating_across_pages_are_furniture(self):
pages = [["SCIENTIFIC AMERICAN", f"unique body {i}"] for i in range(10)]
repeated = find_repeated_lines(pages)
self.assertIn("SCIENTIFIC AMERICAN", repeated)
self.assertNotIn("unique body 3", repeated)
def test_no_false_positives_on_a_short_document(self):
self.assertEqual(find_repeated_lines([["a"], ["b"]]), set())
class ContentsPageTests(unittest.TestCase):
def test_contents_page_scores_above_a_feature_page(self):
toc = [
"Contents",
"Features",
"The future of batteries ......... 18",
"Inside the deep ocean ........... 30",
"The new space race .............. 44",
"Departments",
"Books ........................... 60",
"Reviews ......................... 64",
]
feature = ["The Future of Batteries", "By Jane Smith",
"Battery technology is entering a new phase. " * 20]
self.assertGreater(score_contents_page(toc), 0.45)
self.assertLess(score_contents_page(feature), 0.45)
def test_entries_parse_with_section_and_page(self):
entries = parse_contents([
"Contents",
"Features",
"The future of batteries ......... 18",
"Inside the deep ocean ........... 30",
"Departments",
"Reviews ......................... 64",
])
self.assertEqual(len(entries), 3)
self.assertEqual(entries[0], {"title": "The future of batteries",
"page": 18, "section": "Features"})
self.assertEqual(entries[2]["section"], "Departments")
def test_garbage_entries_are_rejected(self):
self.assertEqual(parse_contents(["ab .... 99999", "x . 3"]), [])
class ContinuationTests(unittest.TestCase):
def test_continued_on_and_from_are_found(self):
self.assertEqual(find_continuation("(continued on page 88)")[0], 88)
self.assertEqual(find_continuation("Fortsetzung auf Seite 90")[0], 90)
self.assertEqual(find_continuation("continued from page 27")[1], 27)
def test_plain_text_has_no_continuation(self):
self.assertEqual(find_continuation("The page turned slowly."), (None, None))
class OcrParagraphGroupingTests(unittest.TestCase):
"""ocr_page() groups tesseract's TSV by paragraph, not by line -- matching
how the native PyMuPDF path already aggregates a paragraph's spans into
one Block with one representative font size. One Block per line let a
single anomalous line (a chart number, scan noise, a headline's wrapped
second line) independently trigger BLOCK_TITLE classification; confirmed
live as the dominant cause of a 28-page scan producing ~11 "title" blocks
per page instead of grouping around real headlines."""
TSV_HEADER = "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext"
def _row(self, block, par, line, word_num, left, top, width, height, conf, text):
return f"5\t1\t{block}\t{par}\t{line}\t{word_num}\t{left}\t{top}\t{width}\t{height}\t{conf}\t{text}"
def _run(self, rows):
tsv = "\n".join([self.TSV_HEADER] + rows)
fake = mock.Mock(stdout=tsv)
with mock.patch("subprocess.run", return_value=fake):
return ocr_page(pathlib.Path("/fake.png"))
def test_two_lines_of_one_paragraph_become_a_single_block(self):
rows = [
self._row(1, 1, 1, 1, 10, 100, 40, 20, 92, "Battery"),
self._row(1, 1, 1, 2, 55, 100, 40, 20, 91, "technology"),
self._row(1, 1, 2, 1, 10, 125, 60, 20, 90, "continues"),
]
out = self._run(rows)
self.assertEqual(len(out), 1)
self.assertEqual(out[0]["text"], "Battery technology continues")
def test_font_size_uses_median_line_height_not_total_bbox_height(self):
"""The regression this guards: a paragraph's bbox spans every line it
contains, so using that height as font_size scales with line count.
A 5-line normal paragraph would then misread as giant display type."""
rows = [self._row(1, 1, i, 1, 10, 100 + i * 20, 200, 18, 90, "word")
for i in range(1, 6)]
out = self._run(rows)
self.assertEqual(len(out), 1)
# Combined bbox height spans 5 lines (~118px); the real type size is
# each line's own ~18px height.
self.assertLess(out[0]["line_height"], out[0]["bbox"][3])
self.assertAlmostEqual(out[0]["line_height"], 18.0, delta=1.0)
def test_one_anomalous_line_does_not_dominate_the_paragraph_median(self):
"""A single chart-axis number or scan artifact inside an otherwise
normal paragraph must not make the whole paragraph read as a title."""
rows = [
self._row(1, 1, 1, 1, 10, 100, 40, 18, 90, "normal"),
self._row(1, 1, 2, 1, 10, 120, 40, 95, 90, "GIANT"), # one outlier line
self._row(1, 1, 3, 1, 10, 220, 40, 19, 90, "normal"),
]
out = self._run(rows)
self.assertLess(out[0]["line_height"], 30.0)
def test_low_confidence_words_are_dropped(self):
rows = [
self._row(1, 1, 1, 1, 10, 100, 40, 18, 90, "good"),
self._row(1, 1, 1, 2, 55, 100, 40, 18, 5, "garbage"),
]
out = self._run(rows)
self.assertEqual(out[0]["text"], "good")
def test_different_paragraphs_stay_separate_blocks(self):
rows = [
self._row(1, 1, 1, 1, 10, 100, 40, 18, 90, "first"),
self._row(1, 2, 1, 1, 10, 300, 40, 18, 90, "second"),
]
out = self._run(rows)
self.assertEqual(len(out), 2)
def test_tesseract_failure_returns_empty_not_an_exception(self):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
self.assertEqual(ocr_page(pathlib.Path("/fake.png")), [])
class RunningFurnitureFuzzyMatchTests(unittest.TestCase):
"""Grounded in real OCR output from a scanned c't magazine: a running
footer ("c't Wissen 2026") was read differently on nearly every page,
defeating exact-match repetition and producing 26 near-1-per-page
"articles" on a 28-page scan instead of grouping around real headlines."""
def _footer(self, page, text, y=800):
b = block(f"f{page}", page, 60, y, 200, 18, text, size=18.3, btype=BLOCK_TITLE)
return b
def test_ocr_noisy_footer_variants_cluster_as_furniture(self):
variants = ["c't Wissen 2026", "cE KI-WISSEN", "ce Wissen 2136",
"cA Wissen 2036", "Wissen 205", "Wissen 2026",
"Wissen 2025", "ce Wissen 206"]
blocks = [self._footer(i + 1, v) for i, v in enumerate(variants)]
heights = {b.page: PAGE_H for b in blocks}
cluster_running_furniture(blocks, heights)
self.assertTrue(all(b.type == BLOCK_FURNITURE for b in blocks),
[b.type for b in blocks])
def test_a_real_headline_appearing_once_is_not_reclassified(self):
blocks = [self._footer(1, "Wissen 2026"), self._footer(2, "Wissen 2026"),
self._footer(3, "Wissen 2026")]
headline = block("h1", 4, 50, 60, 470, 50,
"The Future of Solid State Batteries", size=26.0,
btype=BLOCK_TITLE)
blocks.append(headline)
heights = {b.page: PAGE_H for b in blocks}
cluster_running_furniture(blocks, heights)
self.assertEqual(headline.type, BLOCK_TITLE)
def test_fewer_than_min_count_repeats_are_left_alone(self):
"""Two coincidental near-matches should not be enough -- avoids
reclassifying a genuinely rare pair of similar short headlines."""
blocks = [self._footer(1, "Wissen 2026"), self._footer(2, "Wissen 2036")]
heights = {b.page: PAGE_H for b in blocks}
cluster_running_furniture(blocks, heights)
self.assertTrue(all(b.type == BLOCK_TITLE for b in blocks))
def test_body_position_text_is_never_reclassified_regardless_of_repetition(self):
"""Only near-edge blocks are candidates -- a repeated body phrase
(e.g. a slogan) must not be swept into furniture."""
blocks = [block(f"b{i}", i + 1, 50, 400, 200, 20, "Wissen 2026", size=18.3)
for i in range(5)]
heights = {b.page: PAGE_H for b in blocks}
cluster_running_furniture(blocks, heights)
self.assertTrue(all(b.type == BLOCK_BODY for b in blocks))
def test_heavily_truncated_ocr_fragment_does_not_falsely_join(self):
variants = ["Arbeitswelt & Wirtschaft"] * 3 + ["& Wi"]
blocks = [self._footer(i + 1, v) for i, v in enumerate(variants)]
heights = {b.page: PAGE_H for b in blocks}
cluster_running_furniture(blocks, heights)
# The three clean repeats still cluster...
self.assertEqual(sum(1 for b in blocks if b.type == BLOCK_FURNITURE), 3)
# ...the badly truncated one is left for a human to catch in review.
self.assertEqual(blocks[3].type, BLOCK_TITLE)
class TitlePlausibilityTests(unittest.TestCase):
def test_ocr_noise_fragments_are_rejected(self):
for junk in ("I", "@)X", ". |", "12", ""):
self.assertFalse(looks_like_real_title(junk), junk)
def test_real_headlines_are_accepted(self):
for title in ("Wie KI Entwickler-Jobs verändert", "AI Jobs", "Batteries"):
self.assertTrue(looks_like_real_title(title), title)
def test_single_garbled_title_does_not_start_an_empty_article(self):
"""The exact failure seen live: a stray 'I' at running-footer size
on an otherwise bodyless page must not spawn its own article."""
blocks = [block("b1", 10, 50, 800, 40, 18, "I", size=18.3, btype=BLOCK_TITLE)]
blocks[0].reading_order = 0
self.assertEqual(group_articles(blocks, [], page_count=28), [])
class ArticleGroupingTests(unittest.TestCase):
def _issue_blocks(self):
blocks = [
block("b0018-000", 18, 50, 60, 470, 50,
"The Future of Batteries", size=26.0, btype=BLOCK_TITLE),
block("b0018-001", 18, 50, 120, 200, 14,
"By Jane Smith", size=10.0, btype=BLOCK_BYLINE),
block("b0018-002", 18, 50, 150, 200, 300,
"Battery copy. " * 40, size=10.0),
block("b0030-000", 30, 50, 60, 470, 50,
"Inside the Deep Ocean", size=26.0, btype=BLOCK_TITLE),
block("b0030-001", 30, 50, 150, 200, 300,
"Ocean copy. " * 40, size=10.0),
]
for i, b in enumerate(blocks):
b.reading_order = i
return blocks
def test_contents_entries_anchor_article_boundaries(self):
contents = [
{"title": "The future of batteries", "page": 18, "section": "Features"},
{"title": "Inside the deep ocean", "page": 30, "section": "Features"},
]
arts = group_articles(self._issue_blocks(), contents, page_count=40)
self.assertEqual(len(arts), 2)
self.assertEqual(arts[0].pages[0], 18)
self.assertEqual(arts[0].author, "Jane Smith")
self.assertEqual(arts[1].pages[0], 30)
# The first article must stop before the second begins.
self.assertNotIn(30, arts[0].pages)
def test_headline_starts_an_article_without_a_contents_entry(self):
arts = group_articles(self._issue_blocks(), [], page_count=40)
self.assertEqual(len(arts), 2)
self.assertLess(arts[0].confidence, 0.9) # weaker signal, flagged for review
def test_furniture_and_ads_never_enter_an_article(self):
blocks = self._issue_blocks()
ad = block("b0019-000", 19, 50, 50, 400, 400, "ADVERTISEMENT",
size=9.0, btype=BLOCK_AD)
folio = block("b0019-001", 19, 520, 820, 20, 12, "19",
size=8.0, btype=BLOCK_FURNITURE)
blocks.extend([ad, folio])
arts = group_articles(blocks, [{"title": "The future of batteries",
"page": 18, "section": ""}], page_count=40)
self.assertNotIn("b0019-000", arts[0].blocks)
self.assertNotIn("b0019-001", arts[0].blocks)
def test_an_advert_page_between_features_is_not_claimed(self):
"""The ad page starts no article, so it must not be swallowed by the
preceding one and highlighted as part of that feature."""
blocks = self._issue_blocks()
ad = block("b0019-000", 19, 50, 50, 400, 400, "ADVERTISEMENT",
size=9.0, btype=BLOCK_AD)
ad.reading_order = 50
blocks.append(ad)
arts = group_articles(blocks, [
{"title": "The future of batteries", "page": 18, "section": ""},
{"title": "Inside the deep ocean", "page": 30, "section": ""},
], page_count=40)
self.assertNotIn(19, arts[0].pages)
self.assertEqual(arts[0].pages, [18])
def test_a_jump_tail_is_absorbed_into_its_originating_article(self):
blocks = self._issue_blocks()
blocks[2].text += " (continued on page 88)"
tail_title = block("b0088-000", 88, 50, 60, 470, 30,
"Batteries, continued", size=18.0, btype=BLOCK_TITLE)
tail_body = block("b0088-001", 88, 50, 110, 200, 300,
"The rest of the battery copy. " * 20, size=10.0)
tail_title.reading_order, tail_body.reading_order = 90, 91
blocks.extend([tail_title, tail_body])
arts = group_articles(blocks, [{"title": "The future of batteries",
"page": 18, "section": ""}], page_count=90)
first = arts[0]
self.assertIn(88, first.pages)
self.assertIn("b0088-001", first.blocks)
# and the tail is no longer a standalone stub
self.assertTrue(all("continued" not in a.title.lower() for a in arts[1:]))
def test_a_page_with_only_a_headline_starts_no_article(self):
"""Covers and full-page adverts carry display type but no copy; each
would otherwise become an entry the reviewer rejects on every issue."""
blocks = [block("b0005-000", 5, 50, 60, 470, 50, "Special Offer",
size=30.0, btype=BLOCK_TITLE)]
blocks[0].reading_order = 0
self.assertEqual(group_articles(blocks, [], page_count=10), [])
def test_a_listed_article_with_no_body_copy_is_low_confidence(self):
"""A contents entry still anchors an article, but an empty one is
flagged rather than silently trusted."""
blocks = [block("b0005-000", 5, 50, 60, 470, 50, "Special Offer",
size=30.0, btype=BLOCK_TITLE)]
blocks[0].reading_order = 0
arts = group_articles(blocks, [{"title": "Special Offer", "page": 5,
"section": ""}], page_count=10)
self.assertEqual(len(arts), 1)
self.assertLess(arts[0].confidence, 0.4)
def test_the_contents_page_is_not_itself_an_article(self):
blocks = self._issue_blocks()
toc = block("b0002-000", 2, 50, 90, 300, 40, "Contents",
size=28.0, btype=BLOCK_TITLE)
toc_entry = block("b0002-001", 2, 60, 175, 300, 20,
"The future of batteries ......... 18", size=11.0)
toc.reading_order, toc_entry.reading_order = 0, 1
blocks.extend([toc, toc_entry])
arts = group_articles(blocks, [], page_count=40, skip_pages={2})
self.assertTrue(all(a.pages[0] != 2 for a in arts))
class OcrQualityScoreTests(unittest.TestCase):
"""Grounded in real output from both OCR paths on a scanned c't magazine.
Deliberately coarse: it only needs to tell "mostly garbage" from
"plausible prose" well enough to gate an expensive vision-OCR escalation
-- not to catch a subtle character substitution inside an otherwise
normal sentence (confirmed live: a misread "KI" -> "K |" inside a real
German sentence still scores high, since 7 of its 8 words are genuine;
that class of error needs a dictionary or language-model check).
"""
def test_pure_symbol_noise_scores_at_or_near_zero(self):
for junk in (". |", "@)X", ""):
self.assertLess(ocr_quality_score(junk), 0.4, junk)
def test_clean_prose_scores_high(self):
for text in (
"Damit stellen die Forscher das Hauptversprechen der KI-Branche infrage.",
"1. Irrtum: KI ist ein allgemeiner Jobkiller",
):
self.assertGreater(ocr_quality_score(text), 0.85, text)
def test_a_single_misread_word_in_an_otherwise_real_sentence_is_not_caught(self):
"""Documents the known limitation rather than hiding it: this is a
coarse gate, not an accuracy detector."""
text = "K | soll Arbeit abnehmen, die Produktivität stei-"
self.assertGreater(ocr_quality_score(text), 0.7, text)
def test_empty_and_whitespace_only_are_zero(self):
self.assertEqual(ocr_quality_score(""), 0.0)
self.assertEqual(ocr_quality_score(" "), 0.0)
class FontSizeAreaEstimateTests(unittest.TestCase):
"""A vision OCR engine returns one box per paragraph, not per line (see
vision_ocr_page's docstring) -- there is no per-line height to read a
font size from, so it must be estimated from the paragraph's area and
its own text length."""
def test_longer_text_in_the_same_box_implies_a_smaller_font(self):
short = estimate_font_size_from_area(300, 100, text_length=50)
long_ = estimate_font_size_from_area(300, 100, text_length=500)
self.assertGreater(short, long_)
def test_degenerate_inputs_return_zero_not_an_error(self):
self.assertEqual(estimate_font_size_from_area(0, 100, 50), 0.0)
self.assertEqual(estimate_font_size_from_area(100, 0, 50), 0.0)
self.assertEqual(estimate_font_size_from_area(100, 100, 0), 0.0)
def test_a_plausible_headline_box_yields_a_plausible_display_size(self):
# "1. Irrtum: KI ist ein allgemeiner Jobkiller" in a 309x42 box.
size = estimate_font_size_from_area(309, 42, text_length=44)
self.assertGreater(size, 10.0)
self.assertLess(size, 40.0)
class VisionOcrRegionParsingTests(unittest.TestCase):
"""Grounded in ocr-baidu-unlimited-ocr's actual response shape, confirmed
live against a real scanned c't magazine page."""
REAL_SAMPLE = (
" Do not use special punctuation or formatting as visual depts. "
"Special formats such as Oalemium namespace.\n"
"text [260, 86, 589, 248]Killo Bertine in Brackmur et B. Bruche der "
"IT-Branche.\n"
"title [605, 294, 914, 336]1. Irrtum: KI ist ein allgemeiner Jobkiller\n"
"footer [65, 953, 143, 965]c.t KI Wissen 2026\n"
"footer [810, 948, 924, 960]Arbeitswelt & Wirtschaft\n"
"page_number [945, 945, 959, 959]7"
)
def test_preamble_before_the_first_marker_is_discarded(self):
"""The engine sometimes prepends instruction-echoing junk, including
the literal decoy phrase '[No text detected]', before real output."""
regions = parse_vision_ocr_regions(self.REAL_SAMPLE)
self.assertNotIn("Do not use", regions[0]["text"])
self.assertNotIn("namespace", regions[0]["text"])
def test_every_labeled_region_is_recovered(self):
regions = parse_vision_ocr_regions(self.REAL_SAMPLE)
labels = [r["label"] for r in regions]
self.assertEqual(labels, ["text", "title", "footer", "footer", "page_number"])
def test_bbox_is_corner_to_corner_not_xywh(self):
regions = parse_vision_ocr_regions(self.REAL_SAMPLE)
footer = regions[2]
self.assertEqual(footer["bbox"], [65.0, 953.0, 78.0, 12.0])
def test_page_number_text_is_preserved(self):
regions = parse_vision_ocr_regions(self.REAL_SAMPLE)
self.assertEqual(regions[-1]["text"], "7")
def test_completely_unstructured_output_yields_no_regions(self):
self.assertEqual(parse_vision_ocr_regions("just some prose, no markers"), [])
def test_empty_input_yields_no_regions(self):
self.assertEqual(parse_vision_ocr_regions(""), [])
def test_a_degenerate_zero_area_box_is_dropped(self):
self.assertEqual(parse_vision_ocr_regions("text [10,10,10,10]junk"), [])
class VisionBlockBuildingTests(unittest.TestCase):
"""An explicit engine label (footer/title/page_number/caption) is trusted
outright rather than re-derived from font size, which vision OCR gives no
reliable measurement of -- only "text" (or an unrecognised label) is left
ambiguous for the normal classification pass."""
def test_footer_label_becomes_furniture_and_is_protected_from_reclassification(self):
regions = [{"label": "footer", "bbox": [10, 900, 100, 20], "text": "c.t Wissen 2026"}]
blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b")
self.assertEqual(blocks[0].type, BLOCK_FURNITURE)
self.assertEqual(blocks[0].source, "vision_label")
# classify_blocks must leave a vision_label block alone even though
# its heuristics, run in isolation, would call it something else.
classify_blocks(blocks, page_height=1049, page_width=897)
self.assertEqual(blocks[0].type, BLOCK_FURNITURE)
def test_title_label_becomes_title_and_survives_classification(self):
regions = [{"label": "title", "bbox": [605, 294, 914, 336],
"text": "1. Irrtum: KI ist ein allgemeiner Jobkiller"}]
blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b")
classify_blocks(blocks, page_height=1049, page_width=897)
self.assertEqual(blocks[0].type, BLOCK_TITLE)
def test_page_number_label_becomes_furniture(self):
regions = [{"label": "page_number", "bbox": [945, 945, 959, 959], "text": "7"}]
blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b")
self.assertEqual(blocks[0].type, BLOCK_FURNITURE)
def test_caption_label_becomes_caption(self):
regions = [{"label": "caption", "bbox": [10, 10, 100, 20], "text": "A lithium cell."}]
blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b")
self.assertEqual(blocks[0].type, BLOCK_CAPTION)
def test_text_label_is_ambiguous_and_goes_through_normal_classification(self):
regions = [{"label": "text", "bbox": [50, 100, 400, 300],
"text": "Ordinary paragraph copy. " * 10}]
blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b")
self.assertEqual(blocks[0].source, "vision_ocr")
classify_blocks(blocks, page_height=1049, page_width=897, base_size=10.0)
self.assertEqual(blocks[0].type, BLOCK_BODY)
def test_an_unrecognised_label_is_also_treated_as_ambiguous(self):
regions = [{"label": "sidebar_thing", "bbox": [50, 100, 400, 300], "text": "Some text"}]
blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b")
self.assertEqual(blocks[0].source, "vision_ocr")
def test_scale_factor_is_applied_to_the_bbox(self):
regions = [{"label": "footer", "bbox": [100, 200, 300, 220], "text": "x"}]
blocks = build_vision_blocks(regions, page_no=1, scale_x=0.5, id_prefix="b")
self.assertEqual(blocks[0].bbox, [50.0, 100.0, 150.0, 110.0])
def test_block_ids_are_unique_and_page_scoped(self):
regions = [{"label": "text", "bbox": [0, 0, 10, 10], "text": "a"},
{"label": "text", "bbox": [0, 20, 10, 10], "text": "b"}]
blocks = build_vision_blocks(regions, page_no=3, scale_x=1.0, id_prefix="b")
self.assertEqual(len({b.id for b in blocks}), 2)
self.assertTrue(all("0003" in b.id for b in blocks))
class VisionOcrPageCallTests(unittest.TestCase):
"""The HTTP call itself, with the network mocked -- vision_ocr_page must
degrade to None on any failure so ingestion can fall back to tesseract
rather than fail outright."""
def _resp(self, status=200, json_body=None):
import requests
resp = mock.Mock(status_code=status)
resp.raise_for_status = mock.Mock(
side_effect=None if status < 400
else requests.exceptions.HTTPError(f"HTTP {status}"))
resp.json = mock.Mock(return_value=json_body or {})
return resp
def test_successful_call_returns_parsed_regions(self):
body = {"choices": [{"message": {"content":
"text [10,10,110,60]Hello there, this is a real sentence."}}]}
with mock.patch("core.magazine.request_with_retries",
return_value=self._resp(json_body=body)):
out = vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "model-x")
self.assertIsNotNone(out)
self.assertEqual(out[0]["label"], "text")
def test_unreachable_engine_returns_none(self):
import requests
with mock.patch("core.magazine.request_with_retries",
side_effect=requests.exceptions.ConnectionError()):
self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m"))
def test_http_error_status_returns_none(self):
with mock.patch("core.magazine.request_with_retries",
return_value=self._resp(status=500)):
self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m"))
def test_unparseable_response_shape_returns_none(self):
with mock.patch("core.magazine.request_with_retries",
return_value=self._resp(json_body={"unexpected": "shape"})):
self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m"))
def test_output_with_zero_usable_regions_returns_none_not_empty_list(self):
"""An empty-but-truthy list would look like success to the caller."""
body = {"choices": [{"message": {"content": "no markers in here at all"}}]}
with mock.patch("core.magazine.request_with_retries",
return_value=self._resp(json_body=body)):
self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m"))
def test_missing_file_returns_none(self):
self.assertIsNone(vision_ocr_page(pathlib.Path("/no/such/file.png"),
"http://engine/v1", "m"))
class ContinuationMarkerStrippingTests(unittest.TestCase):
def test_a_print_layout_cue_is_removed_before_narration(self):
"""Spoken aloud this would read as literal parenthesised text -- a
print convention, not article content."""
text = strip_continuation_markers(
"The promises did not survive contact. (continued on page 8)")
self.assertNotIn("continued", text.lower())
self.assertTrue(text.endswith("contact."))
def test_german_marker_is_also_removed(self):
text = strip_continuation_markers("Mehr dazu. Fortsetzung auf Seite 90")
self.assertNotIn("Seite", text)
def test_ordinary_text_is_unaffected(self):
self.assertEqual(strip_continuation_markers("Nothing special here."),
"Nothing special here.")
class ArticleTextTests(unittest.TestCase):
def test_continuation_marker_is_stripped_from_narrated_output(self):
"""article_text() feeds TTS directly, so the print cue must not
survive into what gets spoken -- even though it stays in layout.json
for the review screen."""
layout = {
"blocks": [{"id": "b1", "page": 1, "type": BLOCK_BODY,
"reading_order": 0, "bbox": [0, 0, 1, 1],
"text": "End of the thought. (continued on page 8)"}],
}
got = article_text(layout, {"blocks": ["b1"]})
self.assertNotIn("continued", got[0]["text"].lower())
def test_only_narratable_blocks_in_reading_order(self):
layout = {
"page_count": 2,
"blocks": [
{"id": "b2", "page": 1, "type": BLOCK_BODY, "reading_order": 2,
"text": "second", "bbox": [0, 0, 1, 1]},
{"id": "b1", "page": 1, "type": BLOCK_TITLE, "reading_order": 1,
"text": "first", "bbox": [0, 0, 1, 1]},
{"id": "cap", "page": 1, "type": BLOCK_CAPTION, "reading_order": 3,
"text": "a caption", "bbox": [0, 0, 1, 1]},
{"id": "ad", "page": 1, "type": BLOCK_AD, "reading_order": 4,
"text": "buy this", "bbox": [0, 0, 1, 1]},
],
}
article = {"blocks": ["b1", "b2", "cap", "ad"]}
got = [b["text"] for b in article_text(layout, article)]
self.assertEqual(got, ["first", "second"])
with_caps = [b["text"] for b in article_text(layout, article,
include_captions=True)]
self.assertEqual(with_caps, ["first", "second", "a caption"])
def test_listening_estimate_counts_only_narratable_words(self):
blocks = [
Block(id="b1", page=1, bbox=[0, 0, 1, 1], type=BLOCK_BODY,
text=" ".join(["word"] * 155)),
Block(id="ad", page=1, bbox=[0, 0, 1, 1], type=BLOCK_AD,
text=" ".join(["spam"] * 500)),
]
from core.magazine import Article
art = Article(id="a1", title="t", blocks=["b1", "ad"])
self.assertEqual(estimate_listen_seconds(blocks, art), 60)
class BuildArticlesTests(unittest.TestCase):
def test_best_contents_candidate_drives_grouping(self):
layout = {
"page_count": 40,
"blocks": [
{"id": "b0018-000", "page": 18, "type": BLOCK_TITLE,
"reading_order": 0, "text": "The Future of Batteries",
"bbox": [50, 60, 470, 50], "font_size": 26.0, "font_name": "",
"bold": True, "column": 0, "confidence": 0.9, "source": "native"},
{"id": "b0018-001", "page": 18, "type": BLOCK_BODY,
"reading_order": 1, "text": "Battery copy. " * 40,
"bbox": [50, 150, 200, 300], "font_size": 10.0, "font_name": "",
"bold": False, "column": 0, "confidence": 0.85, "source": "native"},
],
"contents_candidates": [
{"page": 2, "score": 0.5, "entries": []},
{"page": 3, "score": 0.9, "entries": [
{"title": "The future of batteries", "page": 18,
"section": "Features"}]},
],
}
arts = build_articles(layout)
self.assertEqual(len(arts), 1)
self.assertEqual(arts[0]["section"], "Features")
self.assertGreater(arts[0]["estimated_seconds"], 0)
class SidebarDetectionTests(unittest.TestCase):
"""BLOCK_SIDEBAR is geometry-only, like the rest of this module: a boxed
insert is reliably narrower than the page's regular column grid. This is
a heuristic, not a certainty -- review still shows every sidebar block
and lets it be ticked back in with one click."""
def test_a_narrow_third_column_is_marked_a_sidebar(self):
blocks = [
block("main1", 1, 50, 100, 300, 400, "Main body copy. " * 40),
block("side1", 1, 400, 100, 100, 400, "A boxed insert. " * 10),
]
assign_reading_order(blocks, PAGE_W)
mark_sidebars(blocks, PAGE_W)
self.assertEqual(next(b for b in blocks if b.id == "side1").type, BLOCK_SIDEBAR)
self.assertEqual(next(b for b in blocks if b.id == "main1").type, BLOCK_BODY)
def test_two_similarly_wide_columns_are_left_alone(self):
"""A normal two-column layout must not be mistaken for a sidebar."""
blocks = [
block("l", 1, 50, 100, 200, 400, "Left column. " * 30),
block("r", 1, 280, 100, 200, 400, "Right column. " * 30),
]
assign_reading_order(blocks, PAGE_W)
mark_sidebars(blocks, PAGE_W)
self.assertTrue(all(b.type == BLOCK_BODY for b in blocks))
def test_a_single_column_page_has_no_sidebar_candidates(self):
blocks = [block("a", 1, 50, 100, 480, 400, "Full width copy. " * 40)]
assign_reading_order(blocks, PAGE_W)
mark_sidebars(blocks, PAGE_W)
self.assertEqual(blocks[0].type, BLOCK_BODY)
def test_a_title_or_caption_in_the_narrow_column_is_untouched(self):
"""Only BLOCK_BODY is reclassified -- a caption already correctly
typed should not be relabelled just for sitting in a narrow column."""
blocks = [
block("main1", 1, 50, 100, 300, 400, "Main body copy. " * 40),
block("cap", 1, 400, 100, 100, 30, "A caption.", btype=BLOCK_CAPTION),
]
assign_reading_order(blocks, PAGE_W)
mark_sidebars(blocks, PAGE_W)
self.assertEqual(next(b for b in blocks if b.id == "cap").type, BLOCK_CAPTION)
def test_sidebar_text_is_excluded_from_narration_by_default(self):
"""End-to-end: a detected sidebar must not be swept into the
article's narrated blocks the way an advert page already is not."""
blocks = [
block("t", 18, 50, 60, 470, 50, "The Future of Batteries",
size=26.0, btype=BLOCK_TITLE),
block("main1", 18, 50, 150, 300, 400, "Main body copy. " * 40),
block("side1", 18, 400, 150, 100, 400, "Battery myths. " * 20),
]
assign_reading_order(blocks, PAGE_W)
mark_sidebars(blocks, PAGE_W)
arts = group_articles(blocks, [{"title": "The Future of Batteries",
"page": 18, "section": ""}], page_count=20)
self.assertNotIn("side1", arts[0].blocks)
self.assertIn("main1", arts[0].blocks)
class ImageAssignmentTests(unittest.TestCase):
"""Extracted figures are useless until linked to the article they
illustrate -- assign_images_to_articles is that link."""
def test_an_image_on_a_single_article_page_is_assigned_to_it(self):
art = Article(id="a1", title="T", pages=[5], blocks=["b1"])
blocks = [block("b1", 5, 50, 100, 300, 50, "Some text")]
images = [{"id": "img1", "page": 5, "bbox": [50, 200, 200, 150]}]
assign_images_to_articles(images, blocks, [art])
self.assertEqual(art.images, ["img1"])
def test_an_image_on_a_page_with_no_matching_article_is_skipped(self):
art = Article(id="a1", title="T", pages=[5], blocks=[])
images = [{"id": "img1", "page": 9, "bbox": [50, 200, 200, 150]}]
assign_images_to_articles(images, [], [art])
self.assertEqual(art.images, [])
def test_shared_page_image_goes_to_the_nearer_articles_blocks(self):
"""Two articles land on the same page (one ending, one starting) --
the figure must follow whichever article it actually sits beside."""
art_a = Article(id="a1", title="First", pages=[6], blocks=["ba"])
art_b = Article(id="a2", title="Second", pages=[6], blocks=["bb"])
blocks = [
block("ba", 6, 50, 50, 300, 100, "End of the first article."),
block("bb", 6, 50, 500, 300, 100, "Start of the second article."),
]
# The image sits right next to article B's block (y=520 vs y=500).
images = [{"id": "img1", "page": 6, "bbox": [50, 520, 200, 150]}]
assign_images_to_articles(images, blocks, [art_a, art_b])
self.assertEqual(art_a.images, [])
self.assertEqual(art_b.images, ["img1"])
class ImageReadingPositionTests(unittest.TestCase):
def test_image_takes_the_position_of_the_nearest_block_above_it(self):
blocks = [
block("b1", 1, 50, 50, 300, 100, "First paragraph."),
block("b2", 1, 50, 300, 300, 100, "Second paragraph."),
]
blocks[0].reading_order = 0
blocks[1].reading_order = 1
image = {"page": 1, "bbox": [50, 320, 200, 150]}
self.assertEqual(image_reading_position(image, blocks), 1)
def test_image_above_all_text_falls_back_to_the_nearest_block(self):
blocks = [block("b1", 1, 50, 300, 300, 100, "Paragraph.")]
blocks[0].reading_order = 5
image = {"page": 1, "bbox": [50, 10, 200, 150]}
self.assertEqual(image_reading_position(image, blocks), 5)
def test_no_blocks_on_the_page_returns_zero(self):
self.assertEqual(image_reading_position({"page": 9, "bbox": [0, 0, 1, 1]}, []), 0)
class WholePageVisionFallbackTests(unittest.TestCase):
"""KDL Frontier Parser nano, confirmed live, answers with plain text and
no coordinate markers at all -- unlike ocr-baidu-unlimited-ocr's labeled
regions. _whole_page_region is what lets that still produce something
usable rather than being discarded outright."""
def test_plain_text_with_no_markers_becomes_one_page_sized_region(self):
from core.magazine import _whole_page_region
with mock.patch("PIL.Image.open") as mopen:
mopen.return_value.__enter__.return_value.size = (897, 1049)
regions = _whole_page_region(
"Battery technology is entering a new phase. " * 5,
pathlib.Path("/fake.png"))
self.assertEqual(len(regions), 1)
self.assertEqual(regions[0]["label"], "text")
self.assertEqual(regions[0]["bbox"], [0.0, 0.0, 897.0, 1049.0])
def test_garbage_text_is_rejected_not_wrapped(self):
from core.magazine import _whole_page_region
self.assertIsNone(_whole_page_region(". | @)X", pathlib.Path("/fake.png")))
def test_too_short_text_is_rejected(self):
from core.magazine import _whole_page_region
self.assertIsNone(_whole_page_region("Hi.", pathlib.Path("/fake.png")))
def test_empty_text_is_rejected(self):
from core.magazine import _whole_page_region
self.assertIsNone(_whole_page_region("", pathlib.Path("/fake.png")))
class VisionEngineChainTests(unittest.TestCase):
"""The primary engine (labeled regions, best quality) is tried first; a
differently-shaped secondary is a real fallback, not just tesseract."""
def test_primary_success_short_circuits_the_fallback(self):
with mock.patch("core.magazine.vision_ocr_page") as mocked:
mocked.return_value = [{"label": "text", "bbox": [0, 0, 1, 1], "text": "x"}]
out = vision_ocr_page_chain(pathlib.Path("/fake.png"),
[("http://a", "m1"), ("http://b", "m2")])
self.assertEqual(mocked.call_count, 1)
self.assertIsNotNone(out)
def test_primary_failure_falls_through_to_the_secondary(self):
with mock.patch("core.magazine.vision_ocr_page") as mocked:
mocked.side_effect = [None, [{"label": "text", "bbox": [0, 0, 1, 1], "text": "x"}]]
out = vision_ocr_page_chain(pathlib.Path("/fake.png"),
[("http://a", "m1"), ("http://b", "m2")])
self.assertEqual(mocked.call_count, 2)
self.assertIsNotNone(out)
def test_every_engine_failing_returns_none(self):
with mock.patch("core.magazine.vision_ocr_page", return_value=None):
out = vision_ocr_page_chain(pathlib.Path("/fake.png"),
[("http://a", "m1"), ("http://b", "m2")])
self.assertIsNone(out)
def test_blank_engine_entries_are_skipped_without_calling_out(self):
with mock.patch("core.magazine.vision_ocr_page") as mocked:
mocked.return_value = [{"label": "text", "bbox": [0, 0, 1, 1], "text": "x"}]
vision_ocr_page_chain(pathlib.Path("/fake.png"),
[("", ""), ("http://b", "m2")])
self.assertEqual(mocked.call_count, 1)
self.assertEqual(mocked.call_args[0][1], "http://b")
def test_empty_engine_list_returns_none(self):
self.assertIsNone(vision_ocr_page_chain(pathlib.Path("/fake.png"), []))
if __name__ == "__main__":
unittest.main()