feat(statements): ячейка Excel → Cell: текст по числовому формату, value, ref <лист>!<A1> (#54)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
parent
8b9a5a07c4
commit
ce8372d754
@ -11,7 +11,7 @@ from decimal import Decimal
|
||||
from functools import cached_property
|
||||
from typing import Literal
|
||||
|
||||
__all__ = ["EMPTY_CELL", "MAX_INDEXED_AREA", "Cell", "CellKind", "MergedRange", "Sheet", "column_letter"]
|
||||
__all__ = ["EMPTY_CELL", "MAX_INDEXED_AREA", "Cell", "CellKind", "MergedRange", "Sheet", "cell_ref", "column_letter"]
|
||||
|
||||
CellKind = Literal["empty", "text", "number", "date", "bool"]
|
||||
CellValue = str | Decimal | datetime.date | datetime.datetime | bool | None
|
||||
@ -139,3 +139,13 @@ def column_letter(index: int) -> str:
|
||||
index, remainder = divmod(index - 1, _ALPHABET_SIZE)
|
||||
letters.append(chr(ord("A") + remainder))
|
||||
return "".join(reversed(letters))
|
||||
|
||||
|
||||
def cell_ref(sheet_name: str, row: int, col: int) -> str:
|
||||
"""Адрес ячейки `<лист>!<A1>`; имя листа в одинарных кавычках по правилу Excel (апостроф внутри удваивается).
|
||||
|
||||
Кавычки нужны, если в имени есть что-то кроме букв, цифр и `_` или оно начинается с цифры."""
|
||||
plain = sheet_name != "" and all(c.isalpha() or c.isdigit() or c == "_" for c in sheet_name)
|
||||
if not plain or sheet_name[0].isdigit():
|
||||
sheet_name = "'" + sheet_name.replace("'", "''") + "'"
|
||||
return f"{sheet_name}!{column_letter(col)}{row}"
|
||||
|
||||
70
api/src/parser/statements/cells.py
Normal file
70
api/src/parser/statements/cells.py
Normal file
@ -0,0 +1,70 @@
|
||||
"""Ячейка Excel -> `arm_statements.Cell` (план 4, P4-3…P4-5): текст как показывает Excel, значение, ссылка.
|
||||
|
||||
Адаптер не нормализует: `value` числа — показываемое число (диапазон `numeric(18,2)` проверяет пакет), `text` — то, что
|
||||
видит аналитик. Неразобранные числовые форматы собираются в `unparsed_formats` для предупреждения.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
from collections.abc import Sequence
|
||||
|
||||
from arm_statements import Cell as StatementCell
|
||||
from src.parser.grid import Cell, Sheet, cell_ref
|
||||
from src.parser.statements.number_format import display_amount, general_text
|
||||
|
||||
__all__ = ["excel_cell", "grid_rows"]
|
||||
|
||||
_CONFIDENCE = 100.0
|
||||
_TRUE_TEXT = "ИСТИНА"
|
||||
_FALSE_TEXT = "ЛОЖЬ"
|
||||
|
||||
|
||||
def _number_cell(cell: Cell, unparsed_formats: set[str]) -> tuple[str, object]:
|
||||
raw = cell.value
|
||||
assert raw is not None and not isinstance(raw, str | bool | datetime.date) # noqa: S101 - kind == "number"
|
||||
if cell.number_format is not None:
|
||||
shown = display_amount(raw, cell.number_format)
|
||||
if shown.kind == "value":
|
||||
return str(shown.text), shown.value
|
||||
if shown.kind == "unparsed":
|
||||
unparsed_formats.add(cell.number_format)
|
||||
return general_text(raw), raw
|
||||
|
||||
|
||||
def _text_and_value(cell: Cell, unparsed_formats: set[str]) -> tuple[str, object] | None:
|
||||
match cell.kind:
|
||||
case "number":
|
||||
return _number_cell(cell, unparsed_formats)
|
||||
case "date":
|
||||
value = cell.value
|
||||
assert isinstance(value, datetime.date) # noqa: S101 - kind == "date"
|
||||
return value.strftime("%d.%m.%Y"), value.date() if isinstance(value, datetime.datetime) else value
|
||||
case "bool":
|
||||
return (_TRUE_TEXT if cell.value else _FALSE_TEXT), bool(cell.value)
|
||||
case "text":
|
||||
return cell.text, None
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def excel_cell(sheet: Sheet, row: int, col: int, unparsed_formats: set[str]) -> StatementCell | None:
|
||||
"""Ячейка листа для пакета; пустая и не-левая-верхняя позиция объединённого диапазона -> `None`."""
|
||||
if sheet.merged_origin(row, col) != (row, col):
|
||||
return None
|
||||
converted = _text_and_value(sheet.cell(row, col), unparsed_formats)
|
||||
if converted is None:
|
||||
return None
|
||||
text, value = converted
|
||||
return StatementCell(text=text, value=value, confidence=_CONFIDENCE, ref=cell_ref(sheet.name, row, col)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def grid_rows(
|
||||
sheet: Sheet, first_row: int, last_row: int, width: int, unparsed_formats: set[str]
|
||||
) -> list[tuple[StatementCell | None, ...]]:
|
||||
"""Плотная сетка строк `first_row..last_row` включительно; позиция = `col - 1`, длина строки = `width`."""
|
||||
rows: list[tuple[StatementCell | None, ...]] = []
|
||||
for row in range(first_row, last_row + 1):
|
||||
cells: Sequence[StatementCell | None] = [
|
||||
excel_cell(sheet, row, col, unparsed_formats) for col in range(1, width + 1)
|
||||
]
|
||||
rows.append(tuple(cells))
|
||||
return rows
|
||||
@ -27,9 +27,9 @@ from functools import lru_cache
|
||||
from typing import Literal, NamedTuple
|
||||
|
||||
from src.parser.grid import Cell
|
||||
from src.parser.numbers import quantize_money, to_money
|
||||
from src.parser.numbers import to_money
|
||||
|
||||
__all__ = ["FormatResult", "display_amount"]
|
||||
__all__ = ["FormatResult", "display_amount", "general_text"]
|
||||
|
||||
_PLACEHOLDERS = "0#?"
|
||||
_MAX_PARTS = 4
|
||||
@ -43,16 +43,16 @@ _GENERAL = "general"
|
||||
|
||||
|
||||
class FormatResult(NamedTuple):
|
||||
"""`raw` — General/пусто, действует прежняя логика; `value` — отображаемое значение (None — нет значения);
|
||||
`unparsed` — формат не разобран."""
|
||||
"""`raw` — General/пусто, действует прежняя логика; `value` — отображаемое значение (None — нет значения) и его
|
||||
текст `text`, как показывает Excel; `unparsed` — формат не разобран."""
|
||||
|
||||
kind: Literal["raw", "value", "unparsed"]
|
||||
value: Decimal | None = None
|
||||
text: str | None = None
|
||||
|
||||
|
||||
_RAW = FormatResult("raw")
|
||||
_UNPARSED = FormatResult("unparsed")
|
||||
_NO_VALUE = FormatResult("value", None)
|
||||
|
||||
|
||||
_EXCEL_DIGITS = 15 # Excel сравнивает числа с точностью 15 значащих цифр
|
||||
@ -99,6 +99,9 @@ class _Section:
|
||||
literal: str # показываемый текст (для секций без цифровых плейсхолдеров)
|
||||
scale: int # число масштабирующих запятых
|
||||
decimals: int # число плейсхолдеров после точки
|
||||
grouping: bool = False # есть разделитель групп разрядов
|
||||
prefix: str = "" # литералы до первого плейсхолдера
|
||||
suffix: str = "" # литералы после первого плейсхолдера
|
||||
|
||||
|
||||
def _split_sections(fmt: str) -> list[str] | None:
|
||||
@ -130,16 +133,24 @@ def _split_sections(fmt: str) -> list[str] | None:
|
||||
return sections
|
||||
|
||||
|
||||
def _put(chars: str, literal: list[str], prefix: list[str], suffix: list[str], kinds: list[str]) -> None:
|
||||
"""Литерал секции: общий текст и его положение относительно первого цифрового плейсхолдера."""
|
||||
literal.append(chars)
|
||||
(suffix if "p" in kinds else prefix).append(chars)
|
||||
|
||||
|
||||
def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - линейный разбор символов формата
|
||||
condition: _Condition | None = None
|
||||
literal: list[str] = []
|
||||
prefix: list[str] = []
|
||||
suffix: list[str] = []
|
||||
kinds: list[str] = [] # "p" — плейсхолдер, "," — запятая, "." — точка, прочее не фиксируем
|
||||
index = 0
|
||||
while index < len(text):
|
||||
char = text[index]
|
||||
if char == '"':
|
||||
end = text.index('"', index + 1)
|
||||
literal.append(text[index + 1 : end])
|
||||
_put(text[index + 1 : end], literal, prefix, suffix, kinds)
|
||||
index = end + 1
|
||||
continue
|
||||
if char == "[":
|
||||
@ -155,7 +166,7 @@ def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - ли
|
||||
index = end + 1 # цвет, [$-419] и прочее оформление игнорируются
|
||||
continue
|
||||
if char == "\\":
|
||||
literal.append(text[index + 1 : index + 2])
|
||||
_put(text[index + 1 : index + 2], literal, prefix, suffix, kinds)
|
||||
index += 2
|
||||
continue
|
||||
if char in "_*":
|
||||
@ -170,20 +181,23 @@ def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - ли
|
||||
elif char in ",.":
|
||||
kinds.append(char)
|
||||
elif char in _PLAIN_CHARS:
|
||||
literal.append(char)
|
||||
_put(char, literal, prefix, suffix, kinds)
|
||||
else: # %, E+, /, @, буквы дат и любые неизвестные знаки
|
||||
return None
|
||||
index += 1
|
||||
return _build_section(condition, literal, kinds)
|
||||
return _build_section(condition, literal, kinds, "".join(prefix), "".join(suffix))
|
||||
|
||||
|
||||
def _build_section(condition: _Condition | None, literal: list[str], kinds: list[str]) -> _Section | None:
|
||||
def _build_section(
|
||||
condition: _Condition | None, literal: list[str], kinds: list[str], prefix: str, suffix: str
|
||||
) -> _Section | None:
|
||||
if "g" in kinds:
|
||||
return _Section(condition, True, False, "", 0, 0) if kinds == ["g"] else None
|
||||
last = max((i for i, kind in enumerate(kinds) if kind == "p"), default=-1)
|
||||
if last < 0:
|
||||
return _Section(condition, False, False, "".join(literal).strip(), 0, 0)
|
||||
scale = 0
|
||||
grouping = False
|
||||
position = 0
|
||||
while position < len(kinds):
|
||||
if kinds[position] != ",":
|
||||
@ -195,11 +209,13 @@ def _build_section(condition: _Condition | None, literal: list[str], kinds: list
|
||||
follows_placeholder = position > 0 and kinds[position - 1] == "p"
|
||||
if follows_placeholder and (end == len(kinds) or kinds[end] != "p"):
|
||||
scale += end - position
|
||||
elif follows_placeholder:
|
||||
grouping = True
|
||||
position = end
|
||||
decimals = 0
|
||||
if "." in kinds:
|
||||
decimals = sum(1 for kind in kinds[kinds.index(".") :] if kind == "p")
|
||||
return _Section(condition, False, True, "", scale, decimals)
|
||||
return _Section(condition, False, True, "", scale, decimals, grouping, prefix, suffix)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
@ -239,23 +255,53 @@ def _pick_section(sections: tuple[_Section, ...], raw: Decimal) -> _Section | No
|
||||
return None
|
||||
|
||||
|
||||
def _scaled(raw: Decimal, section: _Section) -> FormatResult:
|
||||
_WIDE = Context(prec=60, rounding=ROUND_HALF_UP) # без потери цифр у чисел длиннее 15 знаков; диапазон — дело пакета
|
||||
_GROUP_SIZE = 3
|
||||
_PRECISION_LIMIT = Decimal(10) ** _EXCEL_DIGITS
|
||||
|
||||
|
||||
def _group_digits(digits: str) -> str:
|
||||
head = len(digits) % _GROUP_SIZE or _GROUP_SIZE
|
||||
parts = [digits[:head], *(digits[i : i + _GROUP_SIZE] for i in range(head, len(digits), _GROUP_SIZE))]
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def general_text(raw: Decimal) -> str:
|
||||
"""Текст числа без формата: целое без дробной части, иначе без хвостовых нулей; запятая, без групп и экспоненты."""
|
||||
if raw == raw.to_integral_value():
|
||||
return format(int(raw), "d")
|
||||
return format(raw.normalize(), "f").replace(".", ",")
|
||||
|
||||
|
||||
def _section_text(magnitude: Decimal, raw_negative: bool, section: _Section, sign: bool) -> str:
|
||||
rounded = magnitude.quantize(Decimal(1).scaleb(-section.decimals), context=_WIDE)
|
||||
whole, _, fraction = format(rounded, "f").partition(".")
|
||||
body = _group_digits(whole) if section.grouping else whole
|
||||
if section.decimals:
|
||||
body = f"{body},{fraction}"
|
||||
minus = "-" if raw_negative and sign else ""
|
||||
return f"{minus}{section.prefix}{body}{section.suffix}"
|
||||
|
||||
|
||||
def _scaled(raw: Decimal, section: _Section, sign: bool) -> FormatResult:
|
||||
digits = min(section.decimals, _MAX_DECIMALS)
|
||||
# 15 значащих цифр, как в Excel, но длинные целые (16+ цифр) показываем без потери цифр
|
||||
precise = _excel_precision(raw) if abs(raw) < _PRECISION_LIMIT else raw
|
||||
try:
|
||||
magnitude = abs(_excel_precision(raw)).scaleb(-_SCALE_STEP * section.scale)
|
||||
rounded = magnitude.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP)
|
||||
except InvalidOperation: # число длиннее точности контекста: заведомо вне numeric(18,2)
|
||||
magnitude = abs(precise).scaleb(-_SCALE_STEP * section.scale, context=_WIDE)
|
||||
rounded = magnitude.quantize(Decimal(1).scaleb(-digits), context=_WIDE)
|
||||
text = _section_text(magnitude, raw < 0, section, sign)
|
||||
except InvalidOperation:
|
||||
return _UNPARSED
|
||||
signed = -rounded if raw < 0 else rounded # знак от сырого значения (R-3)
|
||||
money = quantize_money(signed, str(raw))
|
||||
return _UNPARSED if money.warning is not None else FormatResult("value", money.value)
|
||||
value = (-rounded if raw < 0 else rounded).quantize(Decimal("0.01"), context=_WIDE) # знак от сырого (R-3)
|
||||
return FormatResult("value", value, text)
|
||||
|
||||
|
||||
def _literal_result(section: _Section) -> FormatResult:
|
||||
parsed = to_money(Cell(section.literal, "text"))
|
||||
if parsed.warning is not None:
|
||||
return _UNPARSED
|
||||
return _NO_VALUE if parsed.value is None else FormatResult("value", parsed.value)
|
||||
return FormatResult("value", parsed.value, section.literal)
|
||||
|
||||
|
||||
def display_amount(raw: Decimal, fmt: str) -> FormatResult:
|
||||
@ -268,4 +314,7 @@ def display_amount(raw: Decimal, fmt: str) -> FormatResult:
|
||||
return _RAW
|
||||
if section is None:
|
||||
return _UNPARSED
|
||||
return _scaled(raw, section) if section.has_digits else _literal_result(section)
|
||||
if not section.has_digits:
|
||||
return _literal_result(section)
|
||||
single = len(sections) == 1 and section.condition is None
|
||||
return _scaled(raw, section, single)
|
||||
|
||||
71
api/tests/unit/parser/statements/test_cells.py
Normal file
71
api/tests/unit/parser/statements/test_cells.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""Ячейка Excel -> `Cell` пакета: текст по формату, значение, ссылка, сетка (план 4, задача 3)."""
|
||||
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from gridbuilder import M, at, sheet_from_rows, with_formats
|
||||
|
||||
from arm_statements import Cell
|
||||
from src.parser.statements.cells import excel_cell, grid_rows
|
||||
|
||||
|
||||
def _balance(row: int, col: int, value: Decimal): # noqa: ANN202
|
||||
rows: list[list[object]] = [[] for _ in range(row - 1)]
|
||||
rows.append(at({col: value}))
|
||||
return sheet_from_rows(rows, name="Баланс")
|
||||
|
||||
|
||||
def test_excel_cell_by_number_format() -> None:
|
||||
sheet = with_formats(_balance(27, 4, Decimal("106311116.46")), {(27, 4): "#,##0,"})
|
||||
cell = excel_cell(sheet, 27, 4, set())
|
||||
assert cell == Cell(text="106 311", value=Decimal("106311"), confidence=100.0, ref="Баланс!D27")
|
||||
|
||||
|
||||
def test_unparsed_format_keeps_raw_and_is_recorded() -> None:
|
||||
formats: set[str] = set()
|
||||
sheet = with_formats(sheet_from_rows([[Decimal("0.15")]]), {(1, 1): "0.0%"})
|
||||
cell = excel_cell(sheet, 1, 1, formats)
|
||||
assert cell is not None
|
||||
assert (cell.value, cell.text, formats) == (Decimal("0.15"), "0,15", {"0.0%"})
|
||||
|
||||
|
||||
def test_general_and_no_format_give_raw_value() -> None:
|
||||
cell = excel_cell(sheet_from_rows([[Decimal("7016006635.0")]]), 1, 1, set())
|
||||
assert cell is not None
|
||||
assert (cell.value, cell.text) == (Decimal("7016006635.0"), "7016006635")
|
||||
|
||||
|
||||
def test_date_text_bool_and_merged() -> None:
|
||||
sheet = sheet_from_rows(
|
||||
[
|
||||
[datetime.date(2025, 9, 30), "Итого", True],
|
||||
[M("Шапка", 2), False],
|
||||
]
|
||||
)
|
||||
date_cell = excel_cell(sheet, 1, 1, set())
|
||||
assert date_cell is not None
|
||||
assert (date_cell.text, date_cell.value) == ("30.09.2025", datetime.date(2025, 9, 30))
|
||||
text_cell = excel_cell(sheet, 1, 2, set())
|
||||
assert text_cell is not None
|
||||
assert (text_cell.text, text_cell.value) == ("Итого", None)
|
||||
yes, no = excel_cell(sheet, 1, 3, set()), excel_cell(sheet, 2, 3, set())
|
||||
assert yes is not None and no is not None
|
||||
assert [(yes.text, yes.value), (no.text, no.value)] == [("ИСТИНА", True), ("ЛОЖЬ", False)]
|
||||
origin = excel_cell(sheet, 2, 1, set())
|
||||
assert origin is not None
|
||||
assert origin.ref == "Лист_1!A2"
|
||||
assert excel_cell(sheet, 2, 2, set()) is None # не левая верхняя позиция merged
|
||||
assert excel_cell(sheet, 5, 5, set()) is None # пустая
|
||||
|
||||
|
||||
def test_grid_rows_dense_width() -> None:
|
||||
sheet = sheet_from_rows([["a"], [None, "b", None, Decimal(5)], [], ["z"]])
|
||||
width, count = 6, 3
|
||||
rows = grid_rows(sheet, 1, count, width, set())
|
||||
assert len(rows) == count
|
||||
assert all(len(row) == width for row in rows)
|
||||
assert rows[0][0] is not None and rows[0][0].text == "a"
|
||||
assert rows[1][1] is not None and rows[1][1].ref == "Лист_1!B2"
|
||||
assert rows[1][3] is not None and rows[1][3].value == Decimal(5)
|
||||
assert rows[2] == (None,) * 6
|
||||
assert rows[0][1:] == (None,) * 5
|
||||
@ -4,7 +4,7 @@ from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.parser.statements.number_format import FormatResult, display_amount
|
||||
from src.parser.statements.number_format import FormatResult, display_amount, general_text
|
||||
|
||||
|
||||
def shown(raw: str, fmt: str) -> tuple[str, Decimal | None]:
|
||||
@ -131,19 +131,20 @@ def test_escaped_char_is_decoration() -> None:
|
||||
assert shown("1500", "#,##0\\ \\р") == ("value", Decimal("1500.00"))
|
||||
|
||||
|
||||
def test_huge_value_out_of_range_is_unparsed() -> None:
|
||||
assert shown("1" + "0" * 40, "#,##0") == ("unparsed", None)
|
||||
def test_huge_value_is_returned_without_range_check() -> None:
|
||||
"""P4-4: диапазон numeric(18,2) проверяет пакет (VALUE_OUT_OF_RANGE), адаптер отдаёт показываемое число."""
|
||||
assert shown("1" + "0" * 40, "#,##0") == ("value", Decimal("1" + "0" * 40 + ".00"))
|
||||
|
||||
|
||||
def test_max_numeric_18_2_range() -> None:
|
||||
"""Excel показывает 15 значащих цифр: 16-значное целое округляется до 1E+16, что уже вне numeric(18,2)."""
|
||||
def test_long_integers_keep_all_digits() -> None:
|
||||
"""P4-4: длинные целые показываются без потери цифр, диапазон проверяет пакет."""
|
||||
assert shown("999999999999999", "0") == ("value", Decimal("999999999999999.00"))
|
||||
assert shown("9999999999999999", "0") == ("unparsed", None)
|
||||
assert shown("99999999999999999", "0") == ("unparsed", None)
|
||||
assert shown("9999999999999999", "0") == ("value", Decimal("9999999999999999.00"))
|
||||
assert shown("99999999999999999", "0") == ("value", Decimal("99999999999999999.00"))
|
||||
|
||||
|
||||
def test_scaled_huge_value_fits() -> None:
|
||||
assert shown("99999999999999999000", "0,") == ("unparsed", None)
|
||||
def test_scaled_huge_value_is_not_range_checked() -> None:
|
||||
assert shown("99999999999999999000", "0,") == ("value", Decimal("99999999999999999.00"))
|
||||
assert shown("999999999999999000", "0,") == ("value", Decimal("999999999999999.00"))
|
||||
|
||||
|
||||
@ -153,7 +154,7 @@ def test_condition_compares_with_excel_15_digit_precision() -> None:
|
||||
|
||||
result = display_amount(Decimal("-1486533582.3500001"), fmt)
|
||||
|
||||
assert result == FormatResult("value", Decimal("-1486534"))
|
||||
assert result == FormatResult("value", Decimal("-1486534"), "(1\u00a0486\u00a0534)")
|
||||
|
||||
|
||||
def test_value_is_rounded_to_15_digits_half_up_before_scaling() -> None:
|
||||
@ -165,8 +166,31 @@ def test_condition_operand_is_rounded_to_15_digits() -> None:
|
||||
"""Литерал «-» — «нет значения»; «x» дал бы unparsed по R-5 независимо от условия, поэтому берём прочерк."""
|
||||
result = display_amount(Decimal("0.1234567890123456"), '[=0.1234567890123456]"-"')
|
||||
|
||||
assert result == FormatResult("value", None)
|
||||
assert result == FormatResult("value", None, "-")
|
||||
|
||||
|
||||
def test_condition_that_does_not_match_the_pattern_is_unparsed() -> None:
|
||||
assert shown("0", '[=1E-5]"x"') == ("unparsed", None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "fmt", "value", "text"),
|
||||
[
|
||||
("106311116.46", "#,##0,", "106311", "106 311"),
|
||||
("-646532.4", "#,##0;(#,##0)", "-646532", "(646 532)"),
|
||||
("-1500", "#,##0", "-1500", "-1 500"),
|
||||
("-1500", "#,##0;#,##0", "-1500", "1 500"),
|
||||
("1.5", "0.00", "1.50", "1,50"),
|
||||
("0", '#,##0;-#,##0;"-"', None, "-"),
|
||||
("12345678901234567", "0", "12345678901234567", "12345678901234567"),
|
||||
],
|
||||
)
|
||||
def test_display_text(raw: str, fmt: str, value: str | None, text: str) -> None:
|
||||
shown = display_amount(Decimal(raw), fmt)
|
||||
expected = None if value is None else Decimal(value)
|
||||
assert (shown.kind, shown.value, shown.text) == ("value", expected, text)
|
||||
|
||||
|
||||
def test_general_text() -> None:
|
||||
texts = [general_text(Decimal(x)) for x in ("7016006635.0", "106311116.460", "-0.5")]
|
||||
assert texts == ["7016006635", "106311116,46", "-0,5"]
|
||||
|
||||
@ -5,7 +5,7 @@ from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from src.parser.grid import EMPTY_CELL, Cell, MergedRange, Sheet, column_letter
|
||||
from src.parser.grid import EMPTY_CELL, Cell, MergedRange, Sheet, cell_ref, column_letter
|
||||
|
||||
|
||||
def _sheet(
|
||||
@ -91,3 +91,11 @@ def test_column_letter(index: int, letter: str) -> None:
|
||||
def test_column_letter_rejects_non_positive_index() -> None:
|
||||
with pytest.raises(ValueError, match="1-based"):
|
||||
column_letter(0)
|
||||
|
||||
|
||||
def test_cell_ref_quotes_sheet_name_by_excel_rule() -> None:
|
||||
assert cell_ref("Баланс", 27, 3) == "Баланс!C27"
|
||||
assert cell_ref("Лист_1", 2, 2) == "Лист_1!B2"
|
||||
assert cell_ref("Бухгалтерский баланс. Лист 1", 14, 3) == "'Бухгалтерский баланс. Лист 1'!C14"
|
||||
assert cell_ref("2024", 1, 1) == "'2024'!A1"
|
||||
assert cell_ref("Отчёт д'Артаньяна", 1, 28) == "'Отчёт д''Артаньяна'!AB1"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user