Andrey Epifancev b8bf2f29e4 feat(statements): блоки форм, тип по заголовку и ОКУД, листы-продолжения; classify_sheet без statement_markers
Refs #14
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 15:36:26 +04:00

614 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Словари `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_periods_without_end_marker_raises_runtime_error_with_file_and_key(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "periods.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
del data["end"]
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError) as error:
load_rules(target)
assert "periods.yaml" in str(error.value)
assert "end" in str(error.value)
def test_default_period_end_marker_cuts_at_year_abbreviation_and_year_word(rules: Rules) -> None:
end = rules.periods.end
assert end.search("3 квартал 2024 г. (по подразделениям)") is not None
assert end.search("2024 год") is not None
assert end.search("с 01.01.2025 по 31.03.2025") is None
@pytest.mark.parametrize(
("text", "expected"),
[("январь", 1), ("Мая", 5), ("мая", 5), ("октября", 10), ("Декабрь", 12), ("квартал", None), ("", None)],
)
def test_month_number_by_name_in_any_case(rules: Rules, text: str, expected: int | None) -> None:
assert rules.periods.month_number(text) == expected
@pytest.mark.parametrize(
("text", "expected"),
[
("Октябрь 2024 г. - Сентябрь 2025 г.", (10, 2024, 9, 2025, 12)),
("с октября 2024 по сентябрь 2025 г.", (10, 2024, 9, 2025, 12)),
("апрель - июнь 2025 г.", (4, 2025, 6, 2025, 3)),
("сентябрь - май 2025", (9, 2024, 5, 2025, 9)),
("Январь 2025 г. - Сентябрь 2025 г.", (1, 2025, 9, 2025, 9)),
],
)
def test_month_range_with_years(rules: Rules, text: str, expected: tuple[int, int, int, int, int]) -> None:
found = rules.periods.month_range(text)
assert found is not None
assert (found.start_month, found.start_year, found.end_month, found.end_year, found.months) == expected
@pytest.mark.parametrize("text", ["январь-март 2025 г.", "9 месяцев 2025 г.", "3 квартал 2025 г.", "2025 г.", ""])
def test_month_range_is_none_for_other_period_texts(rules: Rules, text: str) -> None:
assert rules.periods.month_range(text) is None
def test_periods_months_without_all_month_groups_is_rejected(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "periods.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["months"] = "(?P<m01>январ)"
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"periods\.yaml.*m01"):
load_rules(target)
def test_periods_range_without_named_groups_is_rejected(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "periods.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["range"] = "январ"
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"periods\.yaml.*range"):
load_rules(target)
def test_inn_pattern_without_named_group_is_rejected(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["counterparty_inn_suffix"] = [r"\d{10}"]
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"osv_headers\.yaml.*counterparty_inn_suffix"):
load_rules(target)
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(
("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", "Контрагенты, ИНН"),
],
)
def test_service_columns(rules: Rules, kind: str, text: str) -> None:
assert any(p.search(text) for p in rules.headers.service_columns[kind])
def test_service_columns_hold_only_what_the_code_reads(rules: Rules) -> None:
"""R-22: КПП, код, количество и валюта словаря не требуют — они идут в `extra` под своим заголовком."""
assert list(rules.headers.service_columns) == ["inn"]
@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