detect.py: classify_sheet (ОСВ / форма ББ-ОФР / прочее по словарю), find_table_header (строка с ≥ 2 группами сумм, «Дебет/Кредит» под группой в пределах merged-диапазона или до следующей группы, заголовок в 1–4 строки, в т.ч. «Нач. сальдо Дт» в одной ячейке; без «начала»; свёрнутое сальдо в *_debit + collapsed; свёрнутые «Обороты» и половинчатая пара Дт/Кт — InvalidFormatError с названием группы), text_col, inn_col (заголовок «ИНН» или ≥ 80 % значений из 10/12 цифр), right_edge, extra_cols, group_label_rows, first_data_row, is_header_repeat (две группы или копия строки блока). Pattern.search_normalized — ячейка нормализуется один раз (T3.6). osv_groups: известные группировки OTHER перечислены явно, чтобы строка-метка «Основные средства» в неслитом заголовке была частью блока. Хелпер тестов gridbuilder (Sheet из «картинки»). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
534 lines
22 KiB
Python
534 lines
22 KiB
Python
"""Словари `rules/*.yaml` и загрузчик: нормализация текста, паттерны, таблица периодов §1.5, группировки §1.1,
|
||
типы данных §1.3, диагностика битых словарей."""
|
||
|
||
import shutil
|
||
from dataclasses import fields, is_dataclass
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
import yaml
|
||
from pydantic import BaseModel
|
||
|
||
from src.parser.rules import (
|
||
DEFAULT_RULES_DIR,
|
||
OsvGroupRules,
|
||
Pattern,
|
||
Rules,
|
||
first_match,
|
||
load_rules,
|
||
normalize_text,
|
||
)
|
||
|
||
# Символы, которых не должно быть в паттернах словарей: нормализованный текст их не содержит (ё → е, дефисы и
|
||
# неразрывные пробелы → пробел), такой паттерн никогда бы не сработал
|
||
MIN_PATTERNS = 40 # защита от «пустого» обхода дерева словарей
|
||
ISO_CODE_LENGTH = 3
|
||
FORBIDDEN_IN_PATTERN = "ёЁ-\u2010\u2011\u2012\u2013\u2014\u2015\u2212\u00a0\n\r\t"
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def rules() -> Rules:
|
||
return load_rules()
|
||
|
||
|
||
def _all_patterns(node: object) -> list[Pattern]:
|
||
"""Все `Pattern` в дереве словарей (модели, списки, кортежи, словари)."""
|
||
if isinstance(node, Pattern):
|
||
return [node]
|
||
if isinstance(node, BaseModel):
|
||
return _all_patterns(list(dict(node).values()))
|
||
if is_dataclass(node) and not isinstance(node, type):
|
||
return _all_patterns([getattr(node, field.name) for field in fields(node)])
|
||
if isinstance(node, dict):
|
||
return _all_patterns(list(node.values()))
|
||
if isinstance(node, list | tuple):
|
||
return [found for item in node for found in _all_patterns(item)]
|
||
return []
|
||
|
||
|
||
def _copy_rules(tmp_path: Path) -> Path:
|
||
target = tmp_path / "rules"
|
||
shutil.copytree(DEFAULT_RULES_DIR, target, ignore=shutil.ignore_patterns("README.md"))
|
||
return target
|
||
|
||
|
||
# --- normalize_text ---
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("raw", "expected"),
|
||
[
|
||
("Оборотно-сальдовая\nведомость", "оборотно сальдовая ведомость"),
|
||
("ОБОРОТНО\u2013САЛЬДОВАЯ", "оборотно сальдовая"),
|
||
("Оборотно\u2014сальдовая", "оборотно сальдовая"),
|
||
("Счёт\u00a060", "счет 60"),
|
||
(" Сальдо \t на начало ", "сальдо на начало"),
|
||
("Оборотно-\nсальдовая", "оборотно сальдовая"),
|
||
("сальдо\u00adвая", "сальдовая"),
|
||
("Д-т", "д т"),
|
||
("", ""),
|
||
(" ", ""),
|
||
],
|
||
)
|
||
def test_normalize_text(raw: str, expected: str) -> None:
|
||
assert normalize_text(raw) == expected
|
||
|
||
|
||
# --- Pattern ---
|
||
|
||
|
||
def test_pattern_matches_normalized_text() -> None:
|
||
pattern = Pattern.compile("оборотно сальдовая ведомость")
|
||
|
||
assert pattern.search("ОБОРОТНО-САЛЬДОВАЯ\nВедомость по счету 60") is not None
|
||
assert pattern.search("Ведомость") is None
|
||
|
||
|
||
def test_pattern_returns_match_object_for_position_arithmetic() -> None:
|
||
match = Pattern.compile(r"по\s+счету").search("Оборотно-сальдовая ведомость по счёту 58.03 за 2024 г.")
|
||
|
||
assert match is not None
|
||
# Позиции считаются по нормализованному тексту: match.string — он же
|
||
assert match.string[match.start() : match.end()] == "по счету"
|
||
assert match.string[match.end() :].strip().startswith("58.03")
|
||
|
||
|
||
def test_search_normalized_matches_prenormalized_text_without_renormalizing() -> None:
|
||
pattern = Pattern.compile("оборотно сальдовая ведомость")
|
||
normalized = normalize_text("ОБОРОТНО-САЛЬДОВАЯ\nВедомость")
|
||
|
||
assert pattern.search_normalized(normalized) is not None
|
||
# Текст не нормализуется повторно: «сырая» строка с дефисом и заглавными не совпадает
|
||
assert pattern.search_normalized("ОБОРОТНО-САЛЬДОВАЯ ведомость") is None
|
||
|
||
|
||
@pytest.mark.parametrize("bad", ["сальдо на начало-периода", "счёт", "сальдо\nна", "[а-я]+"])
|
||
def test_pattern_rejects_sources_that_normalization_would_never_match(bad: str) -> None:
|
||
with pytest.raises(ValueError, match="нормализ"):
|
||
Pattern.compile(bad)
|
||
|
||
|
||
def test_pattern_rejects_invalid_regex() -> None:
|
||
with pytest.raises(ValueError, match="регуляр"):
|
||
Pattern.compile("(незакрытая")
|
||
|
||
|
||
def test_shipped_patterns_are_written_in_normalized_form(rules: Rules) -> None:
|
||
patterns = _all_patterns(rules)
|
||
|
||
assert len(patterns) > MIN_PATTERNS
|
||
offenders = [p.source for p in patterns if any(ch in p.source for ch in FORBIDDEN_IN_PATTERN)]
|
||
assert offenders == []
|
||
|
||
|
||
# --- Загрузка ---
|
||
|
||
|
||
def test_load_rules_reads_all_four_dictionaries(rules: Rules) -> None:
|
||
assert rules.headers.sheet_markers
|
||
assert set(rules.headers.groups) == {"opening", "turnover", "closing"}
|
||
assert rules.groups.mapping
|
||
assert rules.periods.rules
|
||
assert rules.data_types.header
|
||
|
||
|
||
def test_new_synonym_is_a_data_edit_not_a_code_edit(tmp_path: Path) -> None:
|
||
# Синоним из слов, которых нет в штатном словаре, добавляется строкой в YAML; код не меняется
|
||
target = _copy_rules(tmp_path)
|
||
path = target / "osv_headers.yaml"
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
data["groups"]["opening"].append("перенос с прошлого периода")
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
loaded = load_rules(target)
|
||
|
||
assert first_match([(p, True) for p in loaded.headers.groups["opening"]], "Перенос с прошлого периода") is True
|
||
assert (
|
||
first_match([(p, True) for p in load_rules().headers.groups["opening"]], "Перенос с прошлого периода") is None
|
||
)
|
||
|
||
|
||
def test_missing_required_key_raises_runtime_error_with_file_and_key(tmp_path: Path) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
path = target / "osv_headers.yaml"
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
del data["groups"]
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError) as error:
|
||
load_rules(target)
|
||
|
||
assert "osv_headers.yaml" in str(error.value)
|
||
assert "groups" in str(error.value)
|
||
|
||
|
||
def test_unknown_key_is_rejected_as_probable_typo(tmp_path: Path) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
path = target / "periods.yaml"
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
data["rulez"] = []
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError, match=r"periods\.yaml.*rulez"):
|
||
load_rules(target)
|
||
|
||
|
||
def test_broken_yaml_syntax_raises_runtime_error_with_file(tmp_path: Path) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
(target / "data_types.yaml").write_text("header: [\n - : :", encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError, match=r"data_types\.yaml"):
|
||
load_rules(target)
|
||
|
||
|
||
def test_missing_file_raises_runtime_error_with_file(tmp_path: Path) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
(target / "osv_groups.yaml").unlink()
|
||
|
||
with pytest.raises(RuntimeError, match=r"osv_groups\.yaml"):
|
||
load_rules(target)
|
||
|
||
|
||
def test_pattern_with_yo_in_yaml_is_reported_with_file_and_key(tmp_path: Path) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
path = target / "osv_headers.yaml"
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
data["total_row"].append("всё")
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError, match=r"osv_headers\.yaml.*total_row"):
|
||
load_rules(target)
|
||
|
||
|
||
def test_unknown_group_type_is_rejected(tmp_path: Path) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
path = target / "osv_groups.yaml"
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
data["mapping"].append(["^склад", "WAREHOUSE"])
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError, match=r"osv_groups\.yaml.*mapping"):
|
||
load_rules(target)
|
||
|
||
|
||
# --- Заголовки таблицы (§1.0 п. 1–3, §1.1) ---
|
||
|
||
REAL_GROUP_HEADERS = [
|
||
("Сальдо на начало периода", "opening"),
|
||
("Обороты за период", "turnover"),
|
||
("Сальдо на конец периода", "closing"),
|
||
("Нач. сальдо", "opening"),
|
||
("Нач.сальдо", "opening"),
|
||
("Входящее сальдо", "opening"),
|
||
("Обороты", "turnover"),
|
||
("Оборот за период", "turnover"),
|
||
("Кон. сальдо", "closing"),
|
||
("Исходящее сальдо", "closing"),
|
||
("Сальдо\nна конец\nпериода", "closing"),
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize(("text", "group"), REAL_GROUP_HEADERS)
|
||
def test_group_headers_match_their_group_only(rules: Rules, text: str, group: str) -> None:
|
||
matched = {name for name, patterns in rules.headers.groups.items() if any(p.search(text) for p in patterns)}
|
||
|
||
assert matched == {group}
|
||
|
||
|
||
def test_sheet_title_is_not_a_turnover_header(rules: Rules) -> None:
|
||
title = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
||
|
||
assert not any(p.search(title) for p in rules.headers.groups["turnover"])
|
||
assert not any(p.search(title) for p in rules.headers.groups["opening"])
|
||
assert any(p.search(title) for p in rules.headers.sheet_markers)
|
||
|
||
|
||
def test_initial_balance_synonym_is_not_in_the_default_dictionary(rules: Rules) -> None:
|
||
# «Нач. остаток» добавляет сценарий «новый синоним без релиза кода» (R-8): в штатном словаре его быть не должно
|
||
for text in ("Нач. остаток", "Начальный остаток", "Остаток на начало периода"):
|
||
assert not any(p.search(text) for p in rules.headers.groups["opening"]), text
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["Дебет", "Дт", "Д-т", "дебет, руб."])
|
||
def test_debit_synonyms(rules: Rules, text: str) -> None:
|
||
assert any(p.search(text) for p in rules.headers.debit)
|
||
assert not any(p.search(text) for p in rules.headers.credit)
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["Кредит", "Кт", "К-т"])
|
||
def test_credit_synonyms(rules: Rules, text: str) -> None:
|
||
assert any(p.search(text) for p in rules.headers.credit)
|
||
assert not any(p.search(text) for p in rules.headers.debit)
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["Бухгалтерский баланс", "Форма по ОКУД 0710001", "Отчёт о финансовых результатах"])
|
||
def test_statement_markers(rules: Rules, text: str) -> None:
|
||
assert any(p.search(text) for p in rules.headers.statement_markers)
|
||
assert not any(p.search(text) for p in rules.headers.sheet_markers)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("kind", "text"),
|
||
[
|
||
("account", "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."),
|
||
("account", "Оборотно-сальдовая ведомость по счёту 58.03"),
|
||
("period", "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."),
|
||
("period", "Период: 1 квартал 2026 г."),
|
||
("data_type", "Выводимые данные: БУ (данные бухгалтерского учета)"),
|
||
("data_type", "Выводимые данные: сумма"),
|
||
("currency", "Валюта БУ, НУ: руб."),
|
||
("org_params", "Параметры:"),
|
||
("subkonto", "Детализация по субсчетам, субконто: Контрагенты, Договоры"),
|
||
("subkonto", "Субконто"),
|
||
],
|
||
)
|
||
def test_header_markers(rules: Rules, kind: str, text: str) -> None:
|
||
assert any(p.search(text) for p in rules.headers.header_markers[kind]) # type: ignore[index]
|
||
|
||
|
||
def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None:
|
||
text = "Оборотно-сальдовая ведомость по счёту 58.03 за 3 квартал 2024 г."
|
||
match = next(m for p in rules.headers.header_markers["account"] if (m := p.search(text)))
|
||
|
||
assert match.string[match.end() :].lstrip().startswith("58.03")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("kind", "text"),
|
||
[
|
||
("inn", "ИНН"),
|
||
("inn", "Контрагенты, ИНН"),
|
||
("kpp", "КПП"),
|
||
("code", "Код"),
|
||
("quantity", "Количество"),
|
||
("currency", "Валюта"),
|
||
],
|
||
)
|
||
def test_service_columns(rules: Rules, kind: str, text: str) -> None:
|
||
assert any(p.search(text) for p in rules.headers.service_columns[kind])
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["Итого", "ИТОГО", "Итого по счету"])
|
||
def test_total_row(rules: Rules, text: str) -> None:
|
||
assert any(p.search(text) for p in rules.headers.total_row)
|
||
|
||
|
||
def test_counterparty_named_like_total_is_not_a_total_row(rules: Rules) -> None:
|
||
assert not any(p.search("ООО Итого-Сервис Групп") for p in rules.headers.total_row)
|
||
|
||
|
||
# --- Группировки (§1.1) ---
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("text", "expected"),
|
||
[
|
||
("Счет", "ACCOUNT"),
|
||
("Счета", "ACCOUNT"),
|
||
("Субконто", "ACCOUNT"),
|
||
("Подразделение", "DEPARTMENT"),
|
||
("Подразделения", "DEPARTMENT"),
|
||
("Контрагент", "COUNTERPARTY"),
|
||
("Контрагенты", "COUNTERPARTY"),
|
||
("Контрагенты, ИНН", "COUNTERPARTY"),
|
||
("Договор", "CONTRACT"),
|
||
("Договоры", "CONTRACT"),
|
||
("Договоры контрагентов", "CONTRACT"),
|
||
("Номенклатура", "OTHER"),
|
||
("Валюта", "OTHER"),
|
||
("Статьи затрат", "OTHER"),
|
||
("Основные средства", "OTHER"),
|
||
("", "OTHER"),
|
||
],
|
||
)
|
||
def test_group_type_mapping(rules: Rules, text: str, expected: str) -> None:
|
||
assert rules.groups.classify(text) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"text",
|
||
[
|
||
"Основные средства",
|
||
"Номенклатура",
|
||
"Статьи затрат",
|
||
"Статьи движения денежных средств",
|
||
"Валюты",
|
||
"Нематериальные активы",
|
||
],
|
||
)
|
||
def test_known_other_groupings_are_listed_explicitly(rules: Rules, text: str) -> None:
|
||
# Явная запись OTHER (а не `default`) нужна детектору: строка блока заголовка с таким текстом — метка группировки,
|
||
# а не первая строка данных (реальная формулировка «Основные средства» — ОСВ по счету 01)
|
||
assert first_match(rules.groups.mapping, text) == "OTHER"
|
||
|
||
|
||
def test_group_mapping_is_ordered_pairs_and_first_match_wins() -> None:
|
||
rules = OsvGroupRules.model_validate(
|
||
{"mapping": [["^договор", "CONTRACT"], ["договор", "OTHER"]], "default": "ACCOUNT"}
|
||
)
|
||
|
||
assert rules.classify("Договоры") == "CONTRACT"
|
||
assert rules.classify("Прочее") == "ACCOUNT"
|
||
|
||
|
||
# --- Периоды (§1.5) ---
|
||
|
||
# Каждая строка таблицы §1.5 (все перечисленные в ней написания) → (квартал, нарастающий)
|
||
PERIOD_TABLE = [
|
||
("1 квартал", "Q1", True),
|
||
("I кв.", "Q1", True),
|
||
("3 месяца", "Q1", True),
|
||
("январь-март", "Q1", True),
|
||
("01.01–31.03", "Q1", True),
|
||
("2 квартал", "Q2", False),
|
||
("II кв.", "Q2", False),
|
||
("полугодие", "Q2", True),
|
||
("1 полугодие", "Q2", True),
|
||
("6 месяцев", "Q2", True),
|
||
("январь-июнь", "Q2", True),
|
||
("3 квартал", "Q3", False),
|
||
("III кв.", "Q3", False),
|
||
("9 месяцев", "Q3", True),
|
||
("январь-сентябрь", "Q3", True),
|
||
("4 квартал", "Q4", False),
|
||
("IV кв.", "Q4", False),
|
||
("год", "Q4", True),
|
||
("за 2025 год", "Q4", True),
|
||
("12 месяцев", "Q4", True),
|
||
("годовая", "Q4", True),
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize(("text", "quarter", "cumulative"), PERIOD_TABLE)
|
||
def test_period_table_1_5(rules: Rules, text: str, quarter: str, cumulative: bool) -> None:
|
||
rule = rules.periods.match(text)
|
||
|
||
assert rule is not None, text
|
||
assert (rule.quarter, rule.is_cumulative) == (quarter, cumulative)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("text", "quarter", "cumulative"),
|
||
[
|
||
# Реальные формулировки заголовков фикстур (шапка «за …», как в файле)
|
||
("Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г.", "Q1", True),
|
||
("Оборотно-сальдовая ведомость по счету 60 за 2 квартал 2024 г.", "Q2", False),
|
||
("Оборотно-сальдовая ведомость по счету 62 за 3 квартал 2025 г.", "Q3", False),
|
||
("Оборотно-сальдовая ведомость по счету 66 за 4 квартал 2024 г.", "Q4", False),
|
||
("Оборотно-сальдовая ведомость по счету 01 за 2024 г.", "Q4", True),
|
||
("1 квартал 2025 г.", "Q1", True),
|
||
("2024 г.", "Q4", True),
|
||
("2024", "Q4", True),
|
||
# Варианты написания и порядок специфичных правил
|
||
("за 9 месяцев 2024 года", "Q3", True),
|
||
("за 9 мес. 2024", "Q3", True),
|
||
("за 12 месяцев 2024", "Q4", True),
|
||
("1-й квартал 2024", "Q1", True),
|
||
("2-й квартал 2024", "Q2", False),
|
||
("1 кв. 2024", "Q1", True),
|
||
("Январь - Июнь 2024 г.", "Q2", True),
|
||
("01.01.2025 - 31.03.2025", "Q1", True),
|
||
("01.01.2025 - 30.06.2025", "Q2", True),
|
||
("01.01.2025 - 30.09.2025", "Q3", True),
|
||
("01.01.2025 - 31.12.2025", "Q4", True),
|
||
("за 1 полугодие 2025 г.", "Q2", True),
|
||
],
|
||
)
|
||
def test_period_real_and_variant_phrases(rules: Rules, text: str, quarter: str, cumulative: bool) -> None:
|
||
rule = rules.periods.match(text)
|
||
|
||
assert rule is not None, text
|
||
assert (rule.quarter, rule.is_cumulative) == (quarter, cumulative)
|
||
|
||
|
||
def test_period_rules_are_ordered_specific_before_general(rules: Rules) -> None:
|
||
# «12 месяцев» и «9 месяцев» не должны ловиться более общими правилами (например, «2 месяца»-подобными)
|
||
assert rules.periods.match("12 месяцев").quarter == "Q4" # type: ignore[union-attr]
|
||
assert rules.periods.match("9 месяцев").quarter == "Q3" # type: ignore[union-attr]
|
||
assert rules.periods.match("1 полугодие 2024 г.").quarter == "Q2" # type: ignore[union-attr]
|
||
|
||
|
||
def test_unknown_period_text_gives_none(rules: Rules) -> None:
|
||
assert rules.periods.match("за неделю") is None
|
||
assert rules.periods.match("") is None
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("text", "year"),
|
||
[("за 1 квартал 2025 г.", "2025"), ("2024 г.", "2024"), ("01.01.2023 - 31.03.2023", "2023")],
|
||
)
|
||
def test_period_year_pattern(rules: Rules, text: str, year: str) -> None:
|
||
match = rules.periods.year.search(text)
|
||
|
||
assert match is not None
|
||
assert match.group(0) == year
|
||
|
||
|
||
def test_period_year_pattern_ignores_numbers_that_are_not_years(rules: Rules) -> None:
|
||
assert rules.periods.year.search("счет 60.01 за 1 квартал") is None
|
||
assert rules.periods.year.search("ИНН 3528286057") is None
|
||
|
||
|
||
# --- Типы данных (§1.3) ---
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("text", "expected"),
|
||
[
|
||
("БУ (данные бухгалтерского учета)", "BU"),
|
||
("НУ (данные налогового учета)", "NU"),
|
||
("сумма", "SUM"),
|
||
("Сумма (в рублях)", "SUM"),
|
||
],
|
||
)
|
||
def test_header_data_type(rules: Rules, text: str, expected: str) -> None:
|
||
assert first_match(rules.data_types.header, text) == expected
|
||
|
||
|
||
def test_header_data_type_unknown(rules: Rules) -> None:
|
||
assert first_match(rules.data_types.header, "Прочее") is None
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("text", "expected"),
|
||
[("Вал.", "CURRENCY"), ("вал", "CURRENCY"), ("Кол.", "QUANTITY"), ("Количество", "QUANTITY")],
|
||
)
|
||
def test_row_data_type(rules: Rules, text: str, expected: str) -> None:
|
||
assert first_match(rules.data_types.row, text) == expected
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["Валюта расчетов ООО", "Колесо", "60.01", "АО Русский бисквит"])
|
||
def test_row_data_type_does_not_fire_on_ordinary_rows(rules: Rules, text: str) -> None:
|
||
assert first_match(rules.data_types.row, text) is None
|
||
|
||
|
||
def test_currency_codes_cover_iso_minimum(rules: Rules) -> None:
|
||
required = {"RUB", "USD", "EUR", "CNY", "KZT", "BYN", "GBP", "CHF", "JPY", "TRY", "AED"}
|
||
|
||
assert required <= set(rules.data_types.currency_codes)
|
||
assert all(len(code) == ISO_CODE_LENGTH and code.isupper() for code in rules.data_types.currency_codes)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("text", "code"),
|
||
[("руб.", "RUB"), ("RUB", "RUB"), ("руб", "RUB"), ("Рублей", "RUB"), ("рубли", "RUB"), ("rub", "RUB")],
|
||
)
|
||
def test_currency_aliases_map_synonyms_to_iso_code(rules: Rules, text: str, code: str) -> None:
|
||
assert rules.data_types.currency_code(text) == code
|
||
|
||
|
||
def test_currency_aliases_are_patterns_keyed_by_iso_code(rules: Rules) -> None:
|
||
assert set(rules.data_types.currency_aliases) <= set(rules.data_types.currency_codes)
|
||
assert all(isinstance(p, Pattern) for ps in rules.data_types.currency_aliases.values() for p in ps)
|
||
assert rules.data_types.currency_aliases["RUB"]
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["USD", "usd", "EUR", "CNY"])
|
||
def test_currency_code_recognises_bare_iso_code(rules: Rules, text: str) -> None:
|
||
assert rules.data_types.currency_code(text) == text.upper()
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["", "Контрагенты", "ABC", "60.01"])
|
||
def test_currency_code_unknown(rules: Rules, text: str) -> None:
|
||
assert rules.data_types.currency_code(text) is None
|