"""Раскладка колонок таблицы OCR (§11.1 п. 3): шапка «Код» и вывод по данным. Перенос `_build_layout`/`_infer_layout` из старого ядра.""" import pytest from src.normalizer.ocr.layout import Cell, ColumnLayout, build_layout, cell_text, cells_by_index, infer_layout KNOWN = frozenset({"1110", "1120", "1130", "1150", "2110", "2120"}) def _row(*cells: tuple[int, str, float]) -> dict: return {"row_index": 0, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]} def test_cells_by_index_normalizes_types() -> None: cells = cells_by_index(_row((0, "Выручка", 71.4), (1, None, None))) assert cells == {0: Cell("Выручка", 71.4), 1: Cell("", 0.0)} assert cell_text(cells, 0) == "Выручка" assert cell_text(cells, 7) == "" assert cell_text(cells, None) == "" @pytest.mark.parametrize( ("raw", "expected"), [(float("nan"), 0.0), (150, 100.0), (-5, 0.0), (float("inf"), 0.0), (10**400, 0.0), ("90", 0.0), (None, 0.0)], ) def test_cells_by_index_clamps_confidence(raw: object, expected: float) -> None: """Решение №30: `json.loads` принимает NaN/Infinity — уверенность ячейки приводится к [0, 100], NaN → 0.""" row = {"cells": [{"column_index": 0, "value": "x", "confidence": raw}]} assert cells_by_index(row)[0].confidence == expected class TestBuildLayout: def test_header_with_code_and_years(self) -> None: cells = cells_by_index( _row( (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 30 июня\n2025 г", 100), (4, "На 31 декабря\n2024 г.", 100), (5, "На 31 декабря\n2023 г.", 100), ) ) assert build_layout(cells) == ColumnLayout(label_idx=0, code_idx=1, value_idxs=(2, 4, 5)) def test_label_defaults_to_last_filled_left_of_code(self) -> None: cells = cells_by_index(_row((0, "Пояснения", 100), (1, "Показатель", 100), (2, "Код", 100), (3, "2024", 100))) assert build_layout(cells) == ColumnLayout(label_idx=1, code_idx=2, value_idxs=(3,)) def test_no_code_cell_is_not_a_header(self) -> None: assert build_layout(cells_by_index(_row((0, "Выручка", 100), (1, "2110", 100), (2, "13 070", 100)))) is None class TestInferLayout: def test_aep3_like_rows_without_header(self) -> None: rows = [ cells_by_index( _row( (1, "НИ", 0), (0, "Нематериальные активы", 100), (2, "30127", 60), (4, "| | г", 16), (5, "", 100), ) ), cells_by_index( _row( (1, "1120", 50), (0, "Результаты исследований и", 94), (2, ":", 0), (4, "--", 0), (5, ":", 0), ) ), cells_by_index( _row( (1, "1130", 80), (0, "Нематериальные поисковые", 87), (2, "не ****", 5), (4, "3", 0), (5, "+ :", 0), ) ), cells_by_index( _row( (1, "1150", 100), (0, "Основные средства", 100), (2, "1 234", 100), (4, "5 678", 100), (5, "", 100), ) ), ] layout = infer_layout(rows, KNOWN) assert layout is not None assert layout.code_idx == 1 assert layout.label_idx == 0 # R-1: в колонке 5 только мусор (пусто, ':', '+ :'), настоящих сумм нет # в колонке 4 и мусор, и суммы ('--', '3', '5 678'), но сумм не меньше, чем мусора assert layout.value_idxs == (2, 4) def test_no_known_codes_is_none(self) -> None: rows = [cells_by_index(_row((0, "текст", 100), (1, "мусор", 100)))] assert infer_layout(rows, KNOWN) is None def test_empty_rows_is_none(self) -> None: assert infer_layout([], KNOWN) is None