"""Сборка `statementDocument` из таблицы OCR (spec «Нормализация ответа ИС», D-6, D-7).""" import json import time from dataclasses import replace from datetime import date from decimal import Decimal from pathlib import Path import pytest from src.normalizer.ocr.document import BuiltDocument, build_document from src.normalizer.ocr.header import FileHeader from src.normalizer.rules import Edition, OcrRules, load_rules API_DIR = Path(__file__).resolve().parents[3] RULES = load_rules(API_DIR / "rules") HEADER_2024 = FileHeader( "ООО Ромашка", 100.0, "7700000000", 100.0, "тыс. руб.", "на 31 декабря 2024 г.", date(2024, 12, 31), True ) def _row(index: int, *cells: tuple[int, str, float]) -> dict: return {"row_index": index, "cells": [{"column_index": i, "value": v, "confidence": c} for i, v, c in cells]} def _table(label: str, *rows: dict) -> dict: return {"label": {"form_name": label, "confidence": 100}, "rows": list(rows)} CLEAN_BALANCE = _table( "Бухгалтерский баланс", _row( 0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 31 декабря 2024 г.", 100), (3, "На 31 декабря 2023 г.", 100), ), _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1 234", 100), (3, "(56)", 100)), _row(2, (0, "Итого по разделу I", 100), (1, "1100", 100), (2, "1 234", 100), (3, "-", 100)), ) def _build(table: dict, header: FileHeader = HEADER_2024, index: int = 0) -> BuiltDocument: built = build_document(table, index, "job.json", header, RULES) assert built is not None return built class TestCleanTable: def test_document_fields(self) -> None: built = _build(CLEAN_BALANCE) doc = built.document assert doc.document_type == "BALANCE" assert doc.locator == "file:job.json;table:0" assert doc.header.form_name == "Бухгалтерский баланс" assert doc.header.inn == "7700000000" and doc.header.inn_confidence == 100 assert doc.header.report_date == date(2024, 12, 31) and doc.header.report_date_text == "на 31 декабря 2024 г." assert doc.header.units == "тыс. руб." and doc.header.okud is None assert [(c.column_index, c.label, c.period_date) for c in doc.columns] == [ (0, "На 31 декабря 2024 г.", date(2024, 12, 31)), (1, "На 31 декабря 2023 г.", date(2023, 12, 31)), ] assert doc.checks is None def test_lines_and_values(self) -> None: doc = _build(CLEAN_BALANCE).document assert [line.line_code for line in doc.lines] == ["1150", "1100"] first = doc.lines[0] assert ( first.line_name == "Основные средства" and first.line_code_confidence == 100 and first.line_name_confidence == 100 ) values = first.values assert [v.column_index for v in values] == [0, 1] assert ( values[0].amount == Decimal("1234") and values[0].string_value is None and values[0].is_recognized is True ) assert values[1].amount == Decimal("-56") and values[1].is_recognized is True blank = doc.lines[1].values[1] assert blank.amount is None and blank.string_value is None and blank.is_recognized is True def test_fully_recognized_and_edition(self) -> None: built = _build(CLEAN_BALANCE) assert built.fully_recognized is True and built.warnings == [] and built.edition_id == "66n" class TestNoise: def test_unknown_code_uses_ocr_name_and_caps_confidence(self) -> None: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "Расшифровка", 100), (1, "11501", 100), (2, "1", 100), (3, "2", 100)), _row(2, (0, "ОС", 100), (1, "1150", 90), (2, "1", 100), (3, "2", 100)), ) built = _build(table) doc = built.document # кода нет в справочнике — имя из OCR, уверенность кода не выше 99 (spec: «иначе min(confidence, 99)») assert doc.lines[0].line_code == "11501" and doc.lines[0].line_name == "Расшифровка" assert doc.lines[0].line_code_confidence == 99 and doc.lines[0].line_name_confidence == 100 assert doc.lines[1].line_code == "1150" and doc.lines[1].line_code_confidence == 90 # уже < 100, не поднимается assert doc.lines[1].line_name == "Основные средства" assert built.fully_recognized is False def test_non_integer_code_row_is_dropped(self) -> None: """Перенос `_classify_row`: «1150 :» не целое число → строка считается повторным заголовком и пропускается.""" table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "НМА", 100), (1, "1110", 100), (2, "1", 100), (3, "2", 100)), _row(2, (0, "ОС", 100), (1, "1150 :", 100), (2, "1", 100), (3, "2", 100)), ) assert [line.line_code for line in _build(table).document.lines] == ["1110"] def test_duplicate_code_keeps_first(self) -> None: """Решение №15: DBML уникален по (meta_id, line_code) — повтор кода даёт одну строку и предупреждение.""" table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), _row(2, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100), (3, "8", 100)), ) built = _build(table) assert [line.line_code for line in built.document.lines] == ["1150"] assert built.document.lines[0].values[0].amount == Decimal("1") assert "код 1150 повторяется: оставлена первая строка" in built.warnings and built.fully_recognized is False def test_missing_cells_keep_column_count(self) -> None: """Review Focus 2: у строки нет ячеек под колонками — values всё равно по числу колонок.""" table = _table("Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100))) values = _build(table).document.lines[0].values assert [(v.column_index, v.amount, v.string_value, v.confidence, v.is_recognized) for v in values] == [ (0, None, None, 0, False), (1, None, None, 0, False), ] def test_unverified_amount_is_not_recognized(self) -> None: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "(7744536}", 100), (3, "x", 100)), ) values = _build(table).document.lines[0].values assert ( values[0].amount == Decimal("7744536") and values[0].is_recognized is False and values[0].string_value is None ) assert values[1].amount is None and values[1].is_recognized is False and values[1].string_value == "x" def test_synthetic_header_columns_without_dates(self) -> None: table = _table( "Бухгалтерский баланс", _row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100)), _row(1, (1, "1110", 100), (0, "НМА", 100), (2, "2", 100)), ) built = _build(table) assert [(c.column_index, c.label, c.period_date) for c in built.document.columns] == [(0, "", None)] assert "шапка синтезирована" in " ".join(built.warnings) and built.fully_recognized is False class TestTypeAndEdition: def test_type_by_codes_when_label_unknown(self) -> None: built = _build(_table("Бvxгалтерский баланс", *CLEAN_BALANCE["rows"])) assert built.document.document_type == "BALANCE" and "тип определён по кодам" in built.warnings def test_label_wins_over_codes_with_warning(self) -> None: built = _build(_table("Отчет о финансовых результатах", *CLEAN_BALANCE["rows"])) assert built.document.document_type == "INCOME" assert any(w.startswith("тип по кодам (BALANCE) не совпал с label") for w in built.warnings) def test_other_when_nothing_known(self) -> None: table = _table( "Приложение", CLEAN_BALANCE["rows"][0], _row(1, (0, "a", 100), (1, "5100", 100), (2, "1", 100), (3, "2", 100)), _row(2, (0, "b", 100), (1, "5200", 100), (2, "1", 100), (3, "2", 100)), ) built = _build(table) assert built.document.document_type == "OTHER" and "тип не определён: OTHER" in built.warnings def test_edition_2025_is_empty(self) -> None: header = FileHeader("ООО", 100.0, "7700000000", 100.0, "тыс. руб.", None, None, False) table = _table( "Бухгалтерский баланс", _row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 30 июня 2025 г.", 100)), _row(1, (0, "Основные средства", 100), (1, "1150", 100), (2, "1", 100)), ) built = _build(table, header) assert built.edition_id == "fsbu4" and "редакция fsbu4 не заполнена" in built.warnings assert built.document.lines[0].line_name == "Основные средства" # из OCR assert built.document.lines[0].line_name_confidence == 100 # confidence ячейки наименования assert built.fully_recognized is False def test_edition_by_codes_when_no_dates(self) -> None: header = FileHeader(None, None, None, None, None, None, None, False) table = _table("Бухгалтерский баланс", _row(0, (1, "1150", 100), (0, "ОС", 100), (2, "1", 100))) built = _build(table, header) assert built.edition_id == "66n" and "редакция определена по кодам: 66n" in built.warnings def test_unparsed_table_is_none(self) -> None: assert ( build_document(_table("x", _row(0, (0, "a", 100), (1, "b", 100))), 0, "job.json", HEADER_2024, RULES) is None ) def test_missing_label_omits_form_name() -> None: """Контракт: `form_name` без `null` — при отсутствии label поле не задаётся, сборка не падает.""" table = {"rows": CLEAN_BALANCE["rows"]} built = _build(table) assert built.document.header.form_name is None assert "form_name" not in built.document.to_wire()["header"] def test_table_rows_not_list_is_none() -> None: assert build_document({"label": {"form_name": "x"}, "rows": "oops"}, 0, "job.json", HEADER_2024, RULES) is None REPEATED_HEADER = "повторная шапка изменила число колонок значений" def _head(*dates: str) -> dict: cells = [(0, "Наименование показателя", 100), (1, "Код", 100)] cells += [(2 + i, text, 100) for i, text in enumerate(dates)] return _row(0, *cells) class TestRepeatedHeader: """R-3 (design #21): `columns` — по первой шапке, `values` каждой строки — по позиции этой колонки.""" def test_wider_first_header_pads_missing_values(self) -> None: table = _table( "Бухгалтерский баланс", _head("На 31 декабря 2024 г.", "На 31 декабря 2023 г.", "На 31 декабря 2022 г."), _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100), (4, "3", 100)), _row( 2, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 31 декабря 2024 г.", 100), (3, "На 31 декабря 2023 г.", 100), ), _row(3, (0, "НМА", 100), (1, "1110", 100), (2, "4", 100), (3, "5", 100)), ) built = _build(table) assert [line.line_code for line in built.document.lines] == ["1150", "1110"] assert all(len(line.values) == 3 for line in built.document.lines) last = built.document.lines[1].values[2] assert (last.amount, last.string_value, last.confidence, last.is_recognized) == (None, None, 0, False) assert built.document.lines[1].values[1].amount == Decimal("5") assert built.warnings.count(REPEATED_HEADER) == 1 and built.fully_recognized is False def test_narrower_first_header_drops_extra_indexes(self) -> None: table = _table( "Бухгалтерский баланс", _head("На 31 декабря 2024 г.", "На 31 декабря 2023 г."), _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), _row( 2, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 31 декабря 2024 г.", 100), (3, "На 31 декабря 2023 г.", 100), (4, "На 31 декабря 2022 г.", 100), ), _row(3, (0, "НМА", 100), (1, "1110", 100), (2, "4", 100), (3, "5", 100), (4, "6", 100)), ) built = _build(table) assert [c.column_index for c in built.document.columns] == [0, 1] for line in built.document.lines: assert [v.column_index for v in line.values] == [0, 1] # нет «сироты» с column_index = 2 assert built.document.lines[1].values[1].amount == Decimal("5") assert built.warnings.count(REPEATED_HEADER) == 1 and built.fully_recognized is False def test_same_layout_after_repeated_header_is_silent(self) -> None: table = _table( "Бухгалтерский баланс", *CLEAN_BALANCE["rows"][:2], _row( 5, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 31 декабря 2024 г.", 100), (3, "На 31 декабря 2023 г.", 100), ), _row(6, (0, "Итого по разделу I", 100), (1, "1100", 100), (2, "1", 100), (3, "2", 100)), ) built = _build(table) assert [line.line_code for line in built.document.lines] == ["1150", "1100"] assert built.warnings == [] and built.fully_recognized is True def test_latin_o_in_code_drops_row() -> None: """R-4 (design #22): лукалайки применяются только к датам — «11O0» не целое число, строка отбрасывается.""" table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), _row(2, (0, "Итого", 100), (1, "11O0", 100), (2, "1", 100), (3, "2", 100)), ) assert [line.line_code for line in _build(table).document.lines] == ["1150"] class TestEditionWarnings: def test_empty_edition_gives_single_warning(self) -> None: """R-5 (design #23): пустая fsbu4 — одно «не заполнена», без «код N из редакции 66n» по строкам.""" header = FileHeader("ООО", 100.0, "7700000000", 100.0, "тыс. руб.", None, None, False) table = _table( "Бухгалтерский баланс", _head("На 30 июня 2025 г"), _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)), _row(2, (0, "НМА", 100), (1, "1110", 100), (2, "2", 100)), ) built = _build(table, header) assert built.edition_id == "fsbu4" assert built.warnings == ["редакция fsbu4 не заполнена"] assert not any("из редакции" in w for w in built.warnings) def test_code_from_other_nonempty_edition_is_reported(self) -> None: rules = OcrRules( editions=[ Edition(id="old", valid_until=date(2024, 12, 31), lines={"1150": "Основные средства"}), Edition(id="new", valid_from=date(2025, 1, 1), lines={"1110": "Нематериальные активы"}), ], lookalikes=RULES.lookalikes, labels=RULES.labels, ) table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), _row(2, (0, "НМА", 100), (1, "1110", 100), (2, "3", 100), (3, "4", 100)), ) built = build_document(table, 0, "job.json", HEADER_2024, rules) assert built is not None and built.edition_id == "old" assert built.warnings == ["код 1110 из редакции new"] assert built.document.lines[1].line_name == "НМА" and built.document.lines[1].line_code_confidence == 99 def test_table_without_value_columns() -> None: """R-6 (design #25): нет колонок значений — предупреждение и needs_check; values пустые.""" table = _table( "Бухгалтерский баланс", _row(0, (0, "Наименование показателя", 100), (1, "Код", 100)), _row(1, (0, "ОС", 100), (1, "1150", 100)), ) built = _build(table) assert built.document.columns == [] and built.document.lines[0].values == [] assert "нет колонок значений" in built.warnings and built.fully_recognized is False assert REPEATED_HEADER not in built.warnings class TestHeaderConditions: """D-6: каждое отклонение шапки файла лишает документ статуса «всё распознано».""" @pytest.mark.parametrize( "changes", [ {"inn_confidence": 90.0}, {"inn": None, "inn_confidence": None}, {"organization_confidence": 90.0}, {"organization_name": None, "organization_confidence": None}, {"report_date": None, "report_date_recognized": False}, {"report_date_recognized": False}, ], ids=["inn-low", "inn-none", "org-low", "org-none", "no-date", "date-not-recognized"], ) def test_header_deviation_is_not_fully_recognized(self, changes: dict) -> None: assert _build(CLEAN_BALANCE, replace(HEADER_2024, **changes)).fully_recognized is False def test_baseline_is_fully_recognized(self) -> None: assert _build(CLEAN_BALANCE, HEADER_2024).fully_recognized is True def test_column_without_date_warning() -> None: table = _table( "Бухгалтерский баланс", _head("На отчетную дату"), _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)), ) built = _build(table) assert "колонка 0 без даты: На отчетную дату" in built.warnings assert built.document.columns[0].period_date is None and built.fully_recognized is False def test_edition_not_determined_warning() -> None: header = FileHeader(None, None, None, None, None, None, None, False) table = _table( "Приложение", _row(0, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "На 1 января", 100)), _row(1, (0, "a", 100), (1, "5100", 100), (2, "1", 100)), _row(2, (0, "b", 100), (1, "5200", 100), (2, "2", 100)), ) built = _build(table, header) assert built.edition_id is None and "редакция не определена" in built.warnings assert built.document.lines[0].line_name == "a" and built.fully_recognized is False class TestKnownCodeConfidence: """R-2, ветка «код в выбранной редакции»: confidence ячейки, если текст чистый, иначе не выше 99.""" def test_clean_code_keeps_cell_confidence(self) -> None: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 80), (1, "1150", 80), (2, "1", 100), (3, "2", 100)), ) line = _build(table).document.lines[0] assert line.line_code_confidence == 80 and line.line_name_confidence == 80 assert line.line_name == "Основные средства" def test_dirty_code_is_capped_at_99(self) -> None: """«+1150» проходит `int()`, `clean_value` убирает «+» — текст не равен сырому, уверенность ≤ 99.""" table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "+1150", 100), (2, "1", 100), (3, "2", 100)), ) built = _build(table) line = built.document.lines[0] assert line.line_code == "1150" and line.line_name == "Основные средства" assert line.line_code_confidence == 99 and line.line_name_confidence == 99 assert built.fully_recognized is False class TestMoneyPrecision: """Решение №30: `Money` контракта — не больше двух знаков; ячейка с большим числом знаков не валит таблицу.""" @pytest.mark.parametrize("text", ["1,234", "0,125", "12.3456", "(1,234)"]) def test_more_than_two_decimals_is_not_an_amount(self, text: str) -> None: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)), ) built = _build(table) value = built.document.lines[0].values[0] assert value.amount is None and value.string_value == text and value.is_recognized is False assert value.confidence == 100 assert built.fully_recognized is False def test_neighbours_stay_intact_and_document_is_built(self) -> None: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1,234", 100), (3, "56,78", 100)), _row(2, (0, "НМА", 100), (1, "1110", 100), (2, "7", 100), (3, "8,5", 100)), ) built = _build(table) first, second = built.document.lines assert (first.values[0].amount, first.values[0].string_value) == (None, "1,234") assert (first.values[1].amount, first.values[1].is_recognized) == (Decimal("56.78"), True) assert (second.values[0].amount, second.values[0].is_recognized) == (Decimal("7"), True) assert (second.values[1].amount, second.values[1].is_recognized) == (Decimal("8.5"), True) built.document.to_wire() # контрактная модель сериализуется без ValidationError @pytest.mark.parametrize("text", ["1,23", "1,2", "0", "-5", "1 234,50"]) def test_up_to_two_decimals_is_kept(self, text: str) -> None: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)), ) value = _build(table).document.lines[0].values[0] assert value.amount is not None and value.string_value is None and value.is_recognized is True class TestConfidenceRange: """Решение №30: confidence ячеек приводится к [0, 100], NaN — в 0 (контракт требует 0..100).""" @pytest.mark.parametrize( ("raw", "expected"), [(float("nan"), 0), (150, 100), (-5, 0), (float("inf"), 0), (float("-inf"), 0)] ) def test_out_of_range_cell_confidence(self, raw: float, expected: float) -> None: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "5", raw), (3, "2", 100)), ) built = _build(table) assert built.document.lines[0].values[0].confidence == expected built.document.to_wire() def test_header_with_nan_confidence_builds_document(self) -> None: header = replace(HEADER_2024, inn_confidence=0.0, organization_confidence=0.0) built = _build(CLEAN_BALANCE, header) assert built.document.header.inn_confidence == 0 and built.fully_recognized is False class TestSkippedRowsWarnings: """Решение №29: отброшенные строки — по одному табличному предупреждению каждого вида, needs_check.""" def test_both_warnings_and_not_fully_recognized(self) -> None: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), _row(2, (0, "НМА", 100), (1, "1110 :", 100), (2, "3", 100), (3, "4", 100)), _row(3, (0, "Мусор", 100), (1, "11x0", 100), (2, "3", 100), (3, "4", 100)), _row(4, (0, "БАЛАНС", 100), (1, "", 100), (2, "5", 100), (3, "6", 100)), _row(5, (0, "Итого", 100), (1, "", 100), (2, "7", 100), (3, "", 100)), _row(6, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)), # без сумм — без счёта ) built = _build(table) assert [line.line_code for line in built.document.lines] == ["1150"] assert built.warnings == [ "строк с нечисловым кодом пропущено: 2", "строк без кода с суммами пропущено: 2", ] assert built.fully_recognized is False def test_group_titles_without_amounts_are_silent(self) -> None: table = _table( "Бухгалтерский баланс", CLEAN_BALANCE["rows"][0], _row(1, (0, "I. Внеоборотные активы", 100), (1, "", 100)), _row(2, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)), _row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), ) built = _build(table) assert built.warnings == [] and built.fully_recognized is True def test_aep_like_table_with_synthetic_header(self) -> None: """Как в aep_3: пустой код у «Выручка» (мусор «211(1 |» — в колонке значения) и мусорный код «2.10».""" table = _table( "Отчет о финансовых результатах", _row(0, (0, "Выручка", 100), (1, "", 100), (2, "211(1 |", 71), (3, "8096738", 100)), _row(1, (0, "Себестоимость продаж", 100), (1, "2120", 100), (2, "(5)", 100), (3, "(6)", 100)), _row(2, (0, "Доходы от участия", 100), (1, "2.10", 100), (2, "5060", 100), (3, "*", 100)), _row(3, (0, "Валовая прибыль", 100), (1, "2100", 100), (2, "1", 100), (3, "2", 100)), ) built = _build(table) assert [line.line_code for line in built.document.lines] == ["2120", "2100"] assert "строк с нечисловым кодом пропущено: 1" in built.warnings assert "строк без кода с суммами пропущено: 1" in built.warnings assert "шапка синтезирована: колонки без дат" in built.warnings @pytest.mark.fixtures class TestSkippedRowsRealFixtures: @pytest.mark.parametrize( ("name", "index", "expected"), [ ("aep_3.json", 0, ["строк с нечисловым кодом пропущено: 8", "строк без кода с суммами пропущено: 2"]), ("aep_3.json", 1, ["строк с нечисловым кодом пропущено: 2", "строк без кода с суммами пропущено: 2"]), ("aep_6.json", 0, ["строк с нечисловым кодом пропущено: 2", "строк без кода с суммами пропущено: 19"]), ("aep_6.json", 1, ["строк с нечисловым кодом пропущено: 1", "строк без кода с суммами пропущено: 2"]), ], ) def test_warnings_on_real_tables(self, name: str, index: int, expected: list[str]) -> None: real = Path(API_DIR).parent.parent / "fixtures" / "ocr" / name if not real.is_file(): pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") data_ocr = json.loads(real.read_text(encoding="utf-8"))["data_ocr"] built = build_document(data_ocr["table_structure"][index], index, "job.json", HEADER_2024, RULES) assert built is not None skipped = [w for w in built.warnings if w.startswith(("строк с нечисловым", "строк без кода"))] assert skipped == expected assert built.fully_recognized is False @pytest.mark.parametrize( "label", [ "на 2024" + " " * 4000 + "b", "на 2024" + "\n" * 4000 + "b", "на 2024" + "О" * 5000, ], # «на » — признак колонки значений ids=["spaces", "nl", "look"], ) def test_long_column_label_is_fast_and_has_no_date(label: str) -> None: """Решение №31: подпись колонки длиннее даты не разбирается и не проходит `fix_lookalikes` (нет квадратичного роста через `_columns`).""" table = _table( "Бухгалтерский баланс", _head(label), _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)), ) start = time.perf_counter() built = _build(table) assert time.perf_counter() - start < 1.0 assert built.document.columns[0].period_date is None and built.fully_recognized is False assert any(w.startswith("колонка 0 без даты") for w in built.warnings)