- C-1 (№30): сумма с более чем двумя знаками после запятой не принимается (amount=null, string_value, is_recognized=false); confidence ячеек и шапки приводится к [0, 100], NaN и бесконечность дают 0 - F-A (№28): ошибочный файл набора при успешном понижает статус до needs_check - F-B: «ИНН не распознан» только когда ИНН не извлечён, иначе «ИНН распознан неуверенно» - №29: предупреждения «строк с нечисловым кодом пропущено: N» и «строк без кода с суммами пропущено: N» - I-3 (№31): тексты дат длиннее 200 символов не разбираются и не проходят fix_lookalikes - T1.3 (№33): загрузчик yaml отклоняет повторяющиеся ключи; тавтологичный тест дублей заменён настоящим - гигиена: докстринг amounts.py описывает фактическое поведение (все точки удаляются), мёртвые noqa в ocr/__init__.py Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
181 lines
8.6 KiB
Python
181 lines
8.6 KiB
Python
"""Справочники нормализатора (spec «Справочники нормализации — данные с редакциями форм», D-7)."""
|
||
|
||
from datetime import date
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
import yaml
|
||
|
||
from src.core.config import get_settings
|
||
from src.normalizer.rules import Edition, OcrRules, load_rules
|
||
|
||
API_DIR = Path(__file__).resolve().parents[3]
|
||
RULES_DIR = API_DIR / "rules"
|
||
|
||
|
||
def _write_rules(tmp_path: Path, lines: dict | None = None) -> Path:
|
||
(tmp_path / "statement_lines.yaml").write_text(
|
||
yaml.safe_dump(
|
||
lines
|
||
or {
|
||
"editions": [
|
||
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "Основные средства"}},
|
||
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {}},
|
||
]
|
||
},
|
||
allow_unicode=True,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
(tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "0", "l": "1"}}), encoding="utf-8")
|
||
(tmp_path / "statement_labels.yaml").write_text(
|
||
yaml.safe_dump({"labels": {"бухгалтерский баланс": "BALANCE"}}, allow_unicode=True), encoding="utf-8"
|
||
)
|
||
return tmp_path
|
||
|
||
|
||
class TestRealRules:
|
||
def test_loads_repository_rules(self) -> None:
|
||
rules = load_rules(RULES_DIR)
|
||
ids = [edition.id for edition in rules.editions]
|
||
assert ids == ["66n", "fsbu4"]
|
||
assert rules.editions[0].lines["1150"] == "Основные средства"
|
||
assert rules.editions[0].lines["1120"] == "Результаты исследований и разработок"
|
||
assert rules.lookalikes["О"] == "0"
|
||
assert rules.labels["бухгалтерский баланс"] == "BALANCE"
|
||
assert rules.labels["отчет о финансовых результатах"] == "INCOME"
|
||
|
||
def test_codes_are_four_or_five_digits_with_names(self) -> None:
|
||
"""Дубли кодов проверяет загрузчик yaml (`TestDuplicateKeys`): после `safe_load` в словаре их уже не видно."""
|
||
rules = load_rules(RULES_DIR)
|
||
for edition in rules.editions:
|
||
assert all(code.isdigit() and 4 <= len(code) <= 5 for code in edition.lines), edition.id
|
||
assert all(name.strip() for name in edition.lines.values()), edition.id
|
||
|
||
def test_fsbu4_edition_exists_even_if_empty(self) -> None:
|
||
rules = load_rules(RULES_DIR)
|
||
fsbu4 = next(edition for edition in rules.editions if edition.id == "fsbu4")
|
||
assert fsbu4.valid_from == date(2025, 1, 1)
|
||
|
||
def test_66n_edition_data_integrity(self) -> None:
|
||
rules = load_rules(RULES_DIR)
|
||
edition_66n = next(edition for edition in rules.editions if edition.id == "66n")
|
||
# Проверка точного количества кодов в редакции 66n
|
||
assert len(edition_66n.lines) == 63, f"66n должна содержать 63 кода, а содержит {len(edition_66n.lines)}"
|
||
# Проверка специфических кодов, упомянутых в брифе (опечатка и нестандартные)
|
||
assert edition_66n.lines["1220"] == "Налог на добавленную стоимость по приобретённым ценностям"
|
||
assert edition_66n.lines["1105"] == "Гудвил"
|
||
assert edition_66n.lines["2411"] == "в т.ч. текущий налог на прибыль"
|
||
assert edition_66n.lines["2412"] == "отложенный налог на прибыль"
|
||
|
||
|
||
class TestEditionChoice:
|
||
def test_by_report_date(self, tmp_path: Path) -> None:
|
||
rules = load_rules(_write_rules(tmp_path))
|
||
assert rules.edition_for(date(2024, 12, 31)).id == "66n"
|
||
assert rules.edition_for(date(2025, 6, 30)).id == "fsbu4"
|
||
assert rules.edition_for(None) is None
|
||
|
||
def test_by_codes_prefers_more_hits(self, tmp_path: Path) -> None:
|
||
rules = load_rules(
|
||
_write_rules(
|
||
tmp_path,
|
||
{
|
||
"editions": [
|
||
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "ОС", "1110": "НМА"}},
|
||
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {"1150": "ОС", "1231": "ДЗ долгосрочная"}},
|
||
]
|
||
},
|
||
)
|
||
)
|
||
assert rules.edition_by_codes(["1150", "1231"]).id == "fsbu4"
|
||
assert rules.edition_by_codes(["1150"]).id == "66n" # равенство — первая
|
||
assert rules.edition_by_codes(["9999"]) is None
|
||
|
||
def test_known_codes_and_editions_with(self, tmp_path: Path) -> None:
|
||
rules = load_rules(_write_rules(tmp_path))
|
||
assert rules.known_codes() == frozenset({"1150"})
|
||
assert rules.editions_with("1150") == ["66n"]
|
||
assert rules.editions_with("1110") == []
|
||
|
||
|
||
class TestValidation:
|
||
def test_bad_code_fails_with_file_name(self, tmp_path: Path) -> None:
|
||
_write_rules(tmp_path, {"editions": [{"id": "66n", "lines": {"11A0": "x"}}]})
|
||
with pytest.raises(ValueError, match="statement_lines.yaml"):
|
||
load_rules(tmp_path)
|
||
|
||
def test_missing_file_fails(self, tmp_path: Path) -> None:
|
||
_write_rules(tmp_path)
|
||
(tmp_path / "ocr_lookalikes.yaml").unlink()
|
||
with pytest.raises(ValueError, match="ocr_lookalikes.yaml"):
|
||
load_rules(tmp_path)
|
||
|
||
def test_lookalike_must_map_to_single_digit(self, tmp_path: Path) -> None:
|
||
_write_rules(tmp_path)
|
||
(tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "00"}}), encoding="utf-8")
|
||
with pytest.raises(ValueError, match="ocr_lookalikes.yaml"):
|
||
load_rules(tmp_path)
|
||
|
||
|
||
class TestDuplicateKeys:
|
||
"""Решение №33: повторяющийся ключ yaml — ошибка загрузки (иначе `safe_load` молча оставил бы последний)."""
|
||
|
||
def test_duplicate_line_code_fails_with_file_and_key(self, tmp_path: Path) -> None:
|
||
_write_rules(tmp_path)
|
||
(tmp_path / "statement_lines.yaml").write_text(
|
||
"editions:\n"
|
||
" - id: 66n\n"
|
||
" lines:\n"
|
||
' "1150": "Основные средства"\n'
|
||
' "1110": "Нематериальные активы"\n'
|
||
' "1150": "Другое наименование"\n',
|
||
encoding="utf-8",
|
||
)
|
||
with pytest.raises(ValueError, match="statement_lines.yaml") as caught:
|
||
load_rules(tmp_path)
|
||
assert "повторяющийся ключ '1150'" in str(caught.value) and "line 6" in str(caught.value)
|
||
|
||
def test_duplicate_edition_field_fails(self, tmp_path: Path) -> None:
|
||
_write_rules(tmp_path)
|
||
(tmp_path / "statement_lines.yaml").write_text(
|
||
"editions:\n - id: 66n\n id: other\n lines: {}\n", encoding="utf-8"
|
||
)
|
||
with pytest.raises(ValueError, match="повторяющийся ключ 'id'"):
|
||
load_rules(tmp_path)
|
||
|
||
def test_duplicate_lookalike_fails(self, tmp_path: Path) -> None:
|
||
_write_rules(tmp_path)
|
||
(tmp_path / "ocr_lookalikes.yaml").write_text('digits:\n "О": "0"\n "l": "1"\n "О": "6"\n', encoding="utf-8")
|
||
with pytest.raises(ValueError, match="ocr_lookalikes.yaml") as caught:
|
||
load_rules(tmp_path)
|
||
assert "повторяющийся ключ 'О'" in str(caught.value)
|
||
|
||
def test_duplicate_label_fails(self, tmp_path: Path) -> None:
|
||
_write_rules(tmp_path)
|
||
(tmp_path / "statement_labels.yaml").write_text(
|
||
"labels:\n бухгалтерский баланс: BALANCE\n бухгалтерский баланс: INCOME\n", encoding="utf-8"
|
||
)
|
||
with pytest.raises(ValueError, match="statement_labels.yaml") as caught:
|
||
load_rules(tmp_path)
|
||
assert "повторяющийся ключ 'бухгалтерский баланс'" in str(caught.value)
|
||
|
||
def test_same_code_in_different_editions_is_fine(self, tmp_path: Path) -> None:
|
||
_write_rules(
|
||
tmp_path,
|
||
{
|
||
"editions": [
|
||
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "ОС"}},
|
||
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {"1150": "ОС"}},
|
||
]
|
||
},
|
||
)
|
||
assert load_rules(tmp_path).editions_with("1150") == ["66n", "fsbu4"]
|
||
|
||
|
||
def test_settings_defaults() -> None:
|
||
settings = get_settings()
|
||
assert settings.SCAN_CLEANUP is False
|
||
assert settings.SCAN_RULES_DIR == "rules"
|
||
assert isinstance(Edition, type) and isinstance(OcrRules, type)
|