"""Unit tests for the Phase-1/Phase-3 review helpers (pdf2zh/webapp/review.py).""" import sys from pathlib import Path import fitz sys.path.insert(0, str(Path(__file__).parent.parent)) from pdf2zh.webapp.review import ( add_element, apply_phase1_cell_edit, apply_phase1_edit, apply_phase2_cell_edit, apply_phase2_edit, hex_to_rgb, hit_test, normalize_click, output_page_position, overlay_svg, render_page_plain, render_page_with_boxes, ) def _doc_with(elements): return { "pages": [ { "page_index": 0, "page_width": 300, "page_height": 200, "elements": elements, } ] } class TestHitTest: def test_smallest_box_wins_on_overlap(self): boxes = [ {"elem_idx": 0, "bbox_img": [0, 0, 300, 200], "category": "BYPASS"}, {"elem_idx": 1, "bbox_img": [10, 10, 60, 40], "category": "IN_PLACE"}, ] # inside both -> smaller (idx 1); neither box has cell_idx -> None assert hit_test(boxes, 30, 25) == (1, None) assert hit_test(boxes, 200, 150) == (0, None) # only the big one assert hit_test(boxes, 999, 999) is None # outside all def test_hits_a_table_cell(self): boxes = [ { "elem_idx": 2, "cell_idx": 0, "bbox_img": [0, 0, 50, 20], "category": "TABLE", }, { "elem_idx": 2, "cell_idx": 1, "bbox_img": [50, 0, 100, 20], "category": "TABLE", }, ] assert hit_test(boxes, 10, 10) == (2, 0) assert hit_test(boxes, 60, 10) == (2, 1) class TestApplyPhase1Edit: def test_reclassify_picture_to_pageheader_sets_category(self): doc = _doc_with( [ { "label": "Picture", "category": "BYPASS", "bbox_pdf": [0, 0, 10, 10], "source_text": "", "translated_text": "", } ] ) msg = apply_phase1_edit(doc, 0, 0, "PageHeader", "Chapter 1", bypass=False) assert msg is None elem = doc["pages"][0]["elements"][0] assert elem["label"] == "PageHeader" assert elem["category"] == "FLOWING_TEXT" assert elem["source_text"] == "Chapter 1" def test_bypass_sets_bypass_category(self): doc = _doc_with( [ { "label": "Text", "category": "FLOWING_TEXT", "bbox_pdf": [0, 0, 10, 10], "source_text": "x", "translated_text": "", } ] ) apply_phase1_edit(doc, 0, 0, "Text", "x", bypass=True) assert doc["pages"][0]["elements"][0]["category"] == "BYPASS" def test_unbypass_restores_derived_category(self): doc = _doc_with( [ { "label": "SectionHeader", "category": "BYPASS", "bbox_pdf": [0, 0, 10, 10], "source_text": "x", "translated_text": "", } ] ) apply_phase1_edit(doc, 0, 0, "SectionHeader", "x", bypass=False) assert doc["pages"][0]["elements"][0]["category"] == "FLOWING_TEXT" def test_changing_to_table_is_blocked(self): doc = _doc_with( [ { "label": "Text", "category": "FLOWING_TEXT", "bbox_pdf": [0, 0, 10, 10], "source_text": "x", "translated_text": "", } ] ) msg = apply_phase1_edit(doc, 0, 0, "Table", "x", bypass=False) assert msg is not None assert doc["pages"][0]["elements"][0]["label"] == "Text" # unchanged def test_missing_element_returns_message(self): doc = _doc_with([]) assert apply_phase1_edit(doc, 0, 5, "Text", "x", bypass=False) is not None class TestApplyPhase2Edit: def test_sets_translated_text(self): doc = _doc_with( [ { "label": "Text", "category": "FLOWING_TEXT", "bbox_pdf": [0, 0, 10, 10], "source_text": "hi", "translated_text": "old", } ] ) apply_phase2_edit(doc, 0, 0, "xin chào") assert doc["pages"][0]["elements"][0]["translated_text"] == "xin chào" def _table_doc(): return _doc_with( [ { "label": "Table", "category": "TABLE", "bbox_pdf": [0, 0, 100, 20], "source_text": "", "translated_text": "", "cells": [ { "bbox_pdf": [0, 0, 50, 20], "bbox_text": [0, 0, 50, 20], "source_text": "hi", "translated_text": "", }, { "bbox_pdf": [50, 0, 100, 20], "bbox_text": [50, 0, 100, 20], "source_text": "there", "translated_text": "", }, ], } ] ) class TestApplyPhase1CellEdit: def test_sets_cell_source_text(self): doc = _table_doc() msg = apply_phase1_cell_edit(doc, 0, 0, 1, "xin") assert msg is None assert doc["pages"][0]["elements"][0]["cells"][1]["source_text"] == "xin" def test_missing_cell_returns_message(self): doc = _table_doc() assert apply_phase1_cell_edit(doc, 0, 0, 5, "x") is not None class TestApplyPhase2CellEdit: def test_sets_cell_translated_text(self): doc = _table_doc() msg = apply_phase2_cell_edit(doc, 0, 0, 0, "chào") assert msg is None assert doc["pages"][0]["elements"][0]["cells"][0]["translated_text"] == "chào" def test_missing_cell_returns_message(self): doc = _table_doc() assert apply_phase2_cell_edit(doc, 0, 0, 5, "x") is not None class TestAddElement: def test_append_keeps_indices_and_derives_category(self): doc = _doc_with( [ { "label": "Text", "category": "FLOWING_TEXT", "bbox_pdf": [0, 0, 10, 10], "source_text": "a", "translated_text": "", } ] ) idx = add_element(doc, 0, [20, 20, 80, 40], "Caption", "một chú thích") assert idx == 1 elem = doc["pages"][0]["elements"][1] assert elem["category"] == "IN_PLACE" # Caption -> IN_PLACE assert elem["source_text"] == "một chú thích" assert elem["translated_text"] == "" def test_no_color_keys_when_not_provided(self): doc = _doc_with([]) add_element(doc, 0, [0, 0, 10, 10], "Text", "x") elem = doc["pages"][0]["elements"][0] assert "bg_color" not in elem assert "text_color" not in elem def test_stores_color_overrides_when_provided(self): doc = _doc_with([]) add_element( doc, 0, [0, 0, 10, 10], "Text", "x", bg_color=[255, 200, 0], text_color=[10, 20, 30], ) elem = doc["pages"][0]["elements"][0] assert elem["bg_color"] == [255, 200, 0] assert elem["text_color"] == [10, 20, 30] class TestHexToRgb: def test_six_digit_hex(self): assert hex_to_rgb("#ffcc00") == [255, 204, 0] def test_three_digit_hex_expands(self): assert hex_to_rgb("#fc0") == [255, 204, 0] def test_rgb_and_rgba_forms(self): assert hex_to_rgb("rgb(255, 204, 0)") == [255, 204, 0] assert hex_to_rgb("rgba(255, 204, 0, 0.5)") == [255, 204, 0] def test_bad_values_return_none(self): assert hex_to_rgb(None) is None assert hex_to_rgb("") is None assert hex_to_rgb("#12") is None assert hex_to_rgb("not-a-color") is None assert hex_to_rgb("rgb(1, 2)") is None class TestOutputPagePosition: def test_range(self): assert output_page_position([2, 3, 4], 3) == 1 assert output_page_position([4, 2, 3], 4) == 2 # sorted -> [2,3,4] assert output_page_position([2, 3, 4], 5) is None def test_none_is_identity(self): assert output_page_position(None, 7) == 7 class TestNormalizeClick: def test_tuple_ok(self): assert normalize_click((12, 34)) == (12.0, 34.0) assert normalize_click([12, 34, 0]) == (12.0, 34.0) def test_bad_returns_none(self): assert normalize_click(None) is None assert normalize_click(5) is None class TestRenderPageWithBoxes: def test_boxes_scaled_to_image_pixels(self, tmp_path): pdf_path = tmp_path / "p.pdf" doc = fitz.open() doc.new_page(width=300, height=200) doc.save(str(pdf_path)) doc.close() elements = [ { "label": "Text", "category": "FLOWING_TEXT", "bbox_pdf": [10, 20, 110, 60], "source_text": "", "translated_text": "", }, { "label": "Picture", "category": "BYPASS", "bbox_pdf": [0, 0, 300, 200], "source_text": "", "translated_text": "", }, ] # dpi=72 -> scale 1.0 -> bbox_img == bbox_pdf, image == page size. img, boxes, scale = render_page_with_boxes(str(pdf_path), 0, elements, dpi=72) assert scale == 1.0 assert img.size == (300, 200) assert len(boxes) == 2 assert boxes[0]["bbox_img"] == [10, 20, 110, 60] # hit_test on the returned boxes selects the small element. assert hit_test(boxes, 50, 40) == (0, None) def test_table_draws_one_box_per_cell(self, tmp_path): pdf_path = tmp_path / "p.pdf" doc = fitz.open() doc.new_page(width=300, height=200) doc.save(str(pdf_path)) doc.close() elements = [ { "label": "Table", "category": "TABLE", "bbox_pdf": [0, 0, 100, 20], "source_text": "", "translated_text": "", "cells": [ { "bbox_pdf": [0, 0, 50, 20], "bbox_text": [0, 0, 50, 20], "source_text": "hi", "translated_text": "", }, { "bbox_pdf": [50, 0, 100, 20], "bbox_text": [50, 0, 100, 20], "source_text": "there", "translated_text": "", }, ], } ] _, boxes, _ = render_page_with_boxes(str(pdf_path), 0, elements, dpi=72) assert len(boxes) == 2 # per-cell, not one box for the whole table assert [b["cell_idx"] for b in boxes] == [0, 1] assert all(b["elem_idx"] == 0 for b in boxes) assert hit_test(boxes, 10, 10) == (0, 0) assert hit_test(boxes, 60, 10) == (0, 1) _OVERLAY_ELEMENTS = [ { "label": "Text", "category": "FLOWING_TEXT", "bbox_pdf": [10, 20, 110, 60], "source_text": "", "translated_text": "", }, { "label": "Picture", "category": "BYPASS", "bbox_pdf": [0, 0, 300, 200], "source_text": "", "translated_text": "", }, ] class TestRenderPagePlain: def test_size_matches_render_page_with_boxes(self, tmp_path): pdf_path = tmp_path / "p.pdf" doc = fitz.open() doc.new_page(width=300, height=200) doc.save(str(pdf_path)) doc.close() # dpi=72 -> scale 1.0 -> image == page size, and matches the boxed render. img, size = render_page_plain(str(pdf_path), 0, dpi=72) assert size == (300, 200) assert img.size == (300, 200) boxed, _, _ = render_page_with_boxes(str(pdf_path), 0, [], dpi=72) assert img.size == boxed.size def test_out_of_range_raises(self, tmp_path): pdf_path = tmp_path / "p.pdf" doc = fitz.open() doc.new_page(width=100, height=100) doc.save(str(pdf_path)) doc.close() import pytest with pytest.raises(IndexError): render_page_plain(str(pdf_path), 5, dpi=72) class TestOverlaySvg: def test_boxes_identical_to_render_page_with_boxes(self, tmp_path): pdf_path = tmp_path / "p.pdf" doc = fitz.open() doc.new_page(width=300, height=200) doc.save(str(pdf_path)) doc.close() _, ref_boxes, scale = render_page_with_boxes( str(pdf_path), 0, _OVERLAY_ELEMENTS, dpi=72 ) _, boxes = overlay_svg(_OVERLAY_ELEMENTS, scale, 300, 200) assert boxes == ref_boxes # hit_test behaves the same on both box lists. assert hit_test(boxes, 50, 40) == (0, None) def test_svg_has_rect_per_element_and_highlight_stroke(self): svg, boxes = overlay_svg(_OVERLAY_ELEMENTS, 1.0, 300, 200, highlight_idx=0) assert svg.startswith(' red stroke; bypass element -> gray stroke. assert "rgb(230,30,30)" in svg assert "rgb(150,150,150)" in svg def test_box_tag_shows_label_not_index(self): elements = [ {"label": "Text", "category": "FLOWING_TEXT", "bbox_pdf": [0, 0, 10, 10]}, {"label": "Caption", "category": "IN_PLACE", "bbox_pdf": [20, 0, 30, 10]}, ] svg, _ = overlay_svg(elements, 1.0, 50, 50) assert ">Text" in svg assert ">Caption" in svg assert ">0" not in svg assert ">1" not in svg def test_skips_elements_without_valid_bbox(self): elements = [ {"category": "FLOWING_TEXT"}, # no bbox_pdf {"category": "FLOWING_TEXT", "bbox_pdf": [1, 2, 3]}, # wrong length {"category": "FLOWING_TEXT", "bbox_pdf": [0, 0, 10, 10]}, ] svg, boxes = overlay_svg(elements, 1.0, 50, 50) assert len(boxes) == 1 assert boxes[0]["elem_idx"] == 2 def test_table_draws_one_rect_per_cell_plus_dashed_outer_border(self): elements = [ { "label": "Table", "category": "TABLE", "bbox_pdf": [0, 0, 100, 20], "source_text": "", "translated_text": "", "cells": [ { "bbox_pdf": [0, 0, 50, 20], "bbox_text": [0, 0, 50, 20], "source_text": "hi", "translated_text": "", }, { "bbox_pdf": [50, 0, 100, 20], "bbox_text": [50, 0, 100, 20], "source_text": "there", "translated_text": "", }, ], } ] svg, boxes = overlay_svg(elements, 1.0, 100, 20) assert len(boxes) == 2 # per-cell boxes, not one for the whole table assert [b["cell_idx"] for b in boxes] == [0, 1] # 2 cell rects + 1 dashed outer border rect. assert svg.count("