"""`dwg_review.sources` 单元测试(纯函数 + 图片源;PDF 真机在 verify_pdf_source.py)。 设计约束:本文件**不依赖 pypdfium2**(PDF 读取器惰性 import,只有真处理 PDF 才需要)。 """ import base64 import pytest from dwg_review import config, heatmap, sources from tests.fakes import make_ctx # --------------------------------------------------------------------------- # detect_source / is_file_source # --------------------------------------------------------------------------- @pytest.mark.parametrize( "name,expect", [ ("a.dwg", "cad"), ("a.DXF", "cad"), ("b.pdf", "pdf"), ("c.PDF", "pdf"), ("d.png", "image"), ("e.JPG", "image"), ("f.tiff", "image"), ("g.webp", "image"), ], ) def test_detect_source(name, expect): assert sources.detect_source(name) == expect def test_detect_source_unknown_raises(): with pytest.raises(ValueError): sources.detect_source("x.svg") def test_is_file_source(): assert sources.is_file_source("pdf") and sources.is_file_source("image") assert not sources.is_file_source("cad") # --------------------------------------------------------------------------- # PDF 坐标换算 # --------------------------------------------------------------------------- def test_rotate_point_identity(): assert sources.rotate_point(3, 4, 100, 200, 0) == (3, 4) def test_rotate_point_90_matches_probe(): """/Rotate 90:(x,y) → (y, mw - x)(实测验证过的映射)。""" mw, mh = 842.0, 1191.0 assert sources.rotate_point(0, 0, mw, mh, 90) == (0.0, 842.0) assert sources.rotate_point(mw, mh, mw, mh, 90) == (mh, 0.0) def test_rotate_point_180_and_270_stay_in_range(): mw, mh = 842.0, 1191.0 for rot, (rw, rh) in ((180, (mw, mh)), (270, (mh, mw))): for x, y in ((0, 0), (mw, mh), (100, 200)): px, py = sources.rotate_point(x, y, mw, mh, rot) assert 0 <= px <= rw and 0 <= py <= rh def test_rect_to_world_rot90(): mw, mh = 842.0, 1191.0 # 未旋转的 (10,20)-(30,40) → 旋转后 x 取 [20,40],y 取 [842-30, 842-10] world = sources.rect_to_world((10, 20, 30, 40), mw, mh, 90) assert world == (20.0, 812.0, 40.0, 832.0) def test_crop_of_is_margin_not_rect(): """crop 是「裁掉的边距」(left,bottom,right,top),不是绝对矩形。""" assert sources.crop_of((10, 20, 110, 220), 200, 300) == (10.0, 20.0, 90.0, 80.0) def test_crop_of_clamps_negative(): assert sources.crop_of((-5, -5, 300, 400), 200, 300) == (0.0, 0.0, 0.0, 0.0) # --------------------------------------------------------------------------- # 幅面反查 # --------------------------------------------------------------------------- def test_guess_sheet_size_a3_landscape(): w = 420.0 * config.PT_PER_MM h = 297.0 * config.PT_PER_MM assert sources.guess_sheet_size(w, h) == "A3" def test_guess_sheet_size_a4_portrait(): w = 210.0 * config.PT_PER_MM h = 297.0 * config.PT_PER_MM assert sources.guess_sheet_size(w, h) == "A4" def test_guess_sheet_size_unknown(): assert sources.guess_sheet_size(500, 700) is None # --------------------------------------------------------------------------- # 渲染倍率 / 尺度 # --------------------------------------------------------------------------- def test_tile_zoom_uses_max_edge(): assert sources._tile_zoom((0, 0, 1000, 500), 10.0, 2000) == 2.0 def test_tile_zoom_capped_by_max_zoom(): assert sources._tile_zoom((0, 0, 10, 10), 3.0, 2000) == 3.0 def test_tile_zoom_degenerate_returns_max(): assert sources._tile_zoom((0, 0, 0, 0), 4.0, 1000) == 4.0 def test_file_scales_image_is_one(): assert sources.file_scales("image") == (1.0, 1.0) def test_file_scales_pdf_matches_readable_px(monkeypatch): monkeypatch.setattr(config, "TEXT_HEIGHT_MM", 2.0) monkeypatch.setattr(config, "TEXT_READABLE_PX", 8) monkeypatch.setattr(config, "LINE_READABLE_PX", 6) s_text, s_line = sources.file_scales("pdf") text_pt = 2.0 * config.PT_PER_MM assert s_text == pytest.approx(text_pt / 8) assert s_line == pytest.approx(text_pt / 6) assert s_text < s_line # 文字更细 → 单张覆盖更小 # --------------------------------------------------------------------------- # 墨迹网格(纯 PIL) # --------------------------------------------------------------------------- def _img(size, draw=None, mode="RGB", bg=(255, 255, 255)): from PIL import Image, ImageDraw img = Image.new(mode, size, bg) if draw: draw(ImageDraw.Draw(img)) return img def test_ink_grid_marks_only_dark_regions(): from PIL import ImageDraw # 左半白、右半黑 img = _img((100, 100), lambda d: d.rectangle([50, 0, 99, 99], fill=(0, 0, 0))) grid, boxes = sources.ink_grid(img, (0, 0, 100, 100), div=8, max_edge=0) assert grid["cols"] == 8 and grid["rows"] == 8 occupied = heatmap.occupied_cells(grid) # 只应命中右半(col >= 4) assert occupied and all(c >= 4 for _r, c in occupied) assert boxes and len(boxes) == len(occupied) def test_ink_grid_blank_image_has_no_boxes(): grid, boxes = sources.ink_grid(_img((64, 64)), (0, 0, 64, 64), div=8, max_edge=0) assert boxes == [] def test_ink_grid_degenerate_box(): grid, boxes = sources.ink_grid(_img((10, 10)), (0, 0, 0, 0)) assert boxes == [] and grid["cols"] == 0 def test_ink_grid_counts_reflect_density(): from PIL import ImageDraw # 全黑 → 每格占比 ~1 → counts 应显著大于 1 img = _img((80, 80), lambda d: d.rectangle([0, 0, 79, 79], fill=(0, 0, 0))) grid, _ = sources.ink_grid(img, (0, 0, 80, 80), div=8, max_edge=0) assert max(max(row) for row in grid["counts"]) >= 50 def test_ink_grid_respects_threshold(): from PIL import ImageDraw # 灰 210 比阈值 200 亮 → 不算墨迹 img = _img((40, 40), lambda d: d.rectangle([0, 0, 39, 39], fill=(210, 210, 210))) _grid, boxes = sources.ink_grid(img, (0, 0, 40, 40), div=8, max_edge=0) assert boxes == [] def test_ink_grid_box_is_in_world_coords(): from PIL import ImageDraw img = _img((100, 100), lambda d: d.rectangle([0, 0, 49, 49], fill=(0, 0, 0))) _grid, boxes = sources.ink_grid(img, (0, 0, 200, 200), div=4, max_edge=0) for b in boxes: assert 0 <= b[0] <= b[2] <= 200 assert 0 <= b[1] <= b[3] <= 200 # --------------------------------------------------------------------------- # ImageReader # --------------------------------------------------------------------------- @pytest.fixture def sample_png(tmp_path): from PIL import ImageDraw img = _img((400, 200), lambda d: d.rectangle([10, 10, 110, 90], fill=(0, 0, 0))) path = tmp_path / "sample.png" img.save(path) return str(path) def test_image_reader_frames(sample_png): reader = sources.ImageReader(sample_png) try: frames = reader.frames() finally: reader.close() assert len(frames) == 1 f = frames[0] assert f["bbox"] == [0.0, 0.0, 400.0, 200.0] assert f["source_type"] == "image" assert f["page_index"] == 0 def test_image_reader_render_crops_world_box(sample_png): reader = sources.ImageReader(sample_png) try: # 世界 (0,0)-(100,100)(y 向上)→ 图像上半左 100x100 区域 out = reader.render((0, 0, 100, 100), 1.0) finally: reader.close() assert out.size == (100, 100) def test_image_reader_render_never_upscales(sample_png): reader = sources.ImageReader(sample_png) try: out = reader.render((0, 0, 50, 50), 4.0) # zoom>1 不放大 finally: reader.close() assert out.size == (50, 50) def test_image_reader_render_downscales(sample_png): reader = sources.ImageReader(sample_png) try: out = reader.render((0, 0, 400, 200), 0.5) finally: reader.close() assert out.size == (200, 100) def test_image_reader_text_items_empty(sample_png): reader = sources.ImageReader(sample_png) try: assert reader.text_items(0) == [] finally: reader.close() # --------------------------------------------------------------------------- # open_reader / plan_file / collect(图片端到端,离线) # --------------------------------------------------------------------------- def test_open_reader_rejects_cad(): ctx = make_ctx(connect_cad=False) with pytest.raises(ValueError): sources.open_reader(ctx) def test_open_reader_requires_path(): ctx = make_ctx(connect_cad=False, source="image") with pytest.raises(ValueError): sources.open_reader(ctx) def test_plan_file_image(sample_png): ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png) try: plan = sources.plan_file(ctx) finally: sources.close_reader(ctx) assert plan["drawing_info"]["unit"] == "px" assert plan["drawing_info"]["page_count"] == 1 assert len(plan["frames"]) == 1 assert plan["layers"] == {"total": 0, "items": []} def test_plan_file_slices_frames_by_max_frames(sample_png): """max_frames 对文件源同样生效(用假 reader 造多页,避免依赖真实 PDF)。""" ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png, max_frames=2) ctx.source_reader = _FakeReader(page_count=5) plan = sources.plan_file(ctx) assert len(plan["frames"]) == 2 assert plan["drawing_info"]["page_count"] == 5 class _FakeReader: """最小读取器替身:只实现 plan_file 需要的接口。""" def __init__(self, page_count=1): self.page_count = page_count def frames(self): return [ {"bbox": [0.0, 0.0, 10.0, 10.0], "page_index": i, "source_type": "image"} for i in range(self.page_count) ] def close(self): pass def test_collect_image_end_to_end_offline(sample_png, monkeypatch): """图片源取证:同形 evidence + 至少一张整体图 + 世界坐标 window。""" monkeypatch.setattr(config, "TILING_ENABLED", True) monkeypatch.setattr(config, "IMAGE_MAX_EDGE", 1280) ctx = make_ctx( connect_cad=False, source="image", source_path=sample_png, frame_limit=200 ) try: plan = sources.plan_file(ctx) ev = sources.collect(ctx, 1, plan["frames"][0]) finally: sources.close_reader(ctx) assert ev["source_type"] == "image" assert ev["window"] == {"min_x": 0.0, "min_y": 0.0, "max_x": 400.0, "max_y": 200.0} assert ev["entities"] == {"total": 0, "items": []} assert ev["texts"] == {"total": 0, "items": []} assert ev["images"], "至少应有整体图" assert ev["images"][0]["label"] == "overall" # 图片确实可解码 assert base64.b64decode(ev["images"][0]["b64"]).startswith(b"\x89PNG") # 每张图都登记了世界可见范围 for img in ev["images"]: assert len(img["window"]) == 4 def test_collect_image_without_tiling(sample_png, monkeypatch): monkeypatch.setattr(config, "TILING_ENABLED", False) ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png) try: plan = sources.plan_file(ctx) ev = sources.collect(ctx, 1, plan["frames"][0]) finally: sources.close_reader(ctx) assert [i["label"] for i in ev["images"]] == ["overall"] def test_collect_image_bad_bbox_returns_none(sample_png): ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png) try: assert sources.collect(ctx, 1, {"source_type": "image", "bbox": [0, 0, 1]}) is None finally: sources.close_reader(ctx) def test_reader_is_cached_on_ctx(sample_png): ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png) try: r1 = sources.open_reader(ctx) r2 = sources.open_reader(ctx) assert r1 is r2 is ctx.source_reader finally: sources.close_reader(ctx) assert ctx.source_reader is None # --------------------------------------------------------------------------- # snap_box(图片源:世界单位 = 源像素,必须整数对齐) # --------------------------------------------------------------------------- def test_snap_box_outward(): assert sources.snap_box((1.2, 2.7, 10.4, 20.1)) == (1.0, 2.0, 11.0, 21.0) def test_snap_box_with_bounds(): assert sources.snap_box((-5.5, -1.2, 300.4, 50.0), (0.0, 0.0, 100.0, 100.0)) == ( 0.0, 0.0, 100.0, 50.0) def test_snap_box_degenerate_returns_none(): assert sources.snap_box((0.0, 0.0, 0.0, 0.0)) is None assert sources.snap_box(None) is None def test_image_tile_window_matches_pixels_exactly(sample_png, monkeypatch): """图片源瓦片:注册的 window 宽度必须与渲染像素数**精确相等**(倍率恰为 1)。""" import io as _io from PIL import Image as _Image monkeypatch.setattr(config, "TILING_ENABLED", True) ctx = make_ctx(connect_cad=False, source="image", source_path=sample_png) try: plan = sources.plan_file(ctx) ev = sources.collect(ctx, 1, plan["frames"][0]) finally: sources.close_reader(ctx) tiles = [i for i in ev["images"] if i["label"] != "overall"] assert tiles for im in tiles: w = im["window"] img = _Image.open(_io.BytesIO(base64.b64decode(im["b64"]))) assert img.size[0] == int(w[2] - w[0]) assert img.size[1] == int(w[3] - w[1])