grid.py: Cell, MergedRange, Sheet (1-based, только непустые ячейки, ленивые индексы строк и merged; диапазоны больше 10 000 ячеек проверяются перебором), column_letter для диагностики. errors.py: ParserError и коды контракта. readers: read_book определяет формат по сигнатуре (zip → XLSX/XLSB по xl/workbook.bin, OLE2 → XLS), предупреждает о несовпадении с file_type; ошибки библиотек → UnreadableError одной строкой. Числа — Decimal(str(value)), bool отдельно, даты как datetime; xlsb без outline/merged с предупреждением. Тесты: синтетическая книга openpyxl, подмена file_type, мусор/обрезанные файлы, xlsb через подменённый pyxlsb; 66 реальных ОСВ читаются < 5 с с outline и merged; xls «62 за 3 квартал 2024»: строка 15 — outline 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
94 lines
3.5 KiB
Python
94 lines
3.5 KiB
Python
"""Модель листа `Sheet`: merged-диапазоны, порядок ячеек строки, пустота, буквы колонок для диагностики."""
|
||
|
||
import datetime
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
|
||
from src.parser.grid import EMPTY_CELL, Cell, MergedRange, Sheet, column_letter
|
||
|
||
|
||
def _sheet(
|
||
cells: dict[tuple[int, int], Cell],
|
||
merged: tuple[MergedRange, ...] = (),
|
||
row_outline: dict[int, int] | None = None,
|
||
) -> Sheet:
|
||
n_rows = max((row for row, _ in cells), default=0)
|
||
n_cols = max((col for _, col in cells), default=0)
|
||
return Sheet(name="ОСВ", cells=cells, merged=merged, row_outline=row_outline or {}, n_rows=n_rows, n_cols=n_cols)
|
||
|
||
|
||
def test_text_at_inside_merged_range_takes_origin_value() -> None:
|
||
sheet = _sheet(
|
||
{(1, 2): Cell("Сальдо на начало периода", "text"), (2, 2): Cell("Дебет", "text")},
|
||
merged=(MergedRange(top=1, left=2, bottom=1, right=3),),
|
||
)
|
||
|
||
assert sheet.text_at(1, 3) == "Сальдо на начало периода"
|
||
assert sheet.text_at(1, 2) == "Сальдо на начало периода"
|
||
assert sheet.cell(1, 3) is EMPTY_CELL
|
||
assert sheet.merged_origin(1, 3) == (1, 2)
|
||
assert sheet.merged_span(1, 3) == MergedRange(1, 2, 1, 3)
|
||
assert sheet.text_at(2, 3) == ""
|
||
assert sheet.merged_origin(2, 3) == (2, 3)
|
||
assert sheet.merged_span(2, 3) is None
|
||
|
||
|
||
def test_large_merged_range_is_resolved_without_cell_index() -> None:
|
||
# Диапазон на весь столбец: индекс «ячейка → диапазон» для него не строится, поиск всё равно работает
|
||
big = MergedRange(top=1, left=1, bottom=1_048_576, right=16_384)
|
||
sheet = _sheet({(1, 1): Cell("Шапка", "text")}, merged=(big,))
|
||
|
||
assert sheet.merged_origin(500_000, 10_000) == (1, 1)
|
||
assert sheet.text_at(1_048_576, 16_384) == "Шапка"
|
||
|
||
|
||
def test_row_cells_are_ordered_by_column_and_skip_blank_text() -> None:
|
||
sheet = _sheet(
|
||
{
|
||
(3, 5): Cell(Decimal("10.5"), "number"),
|
||
(3, 1): Cell("60.01", "text"),
|
||
(3, 3): Cell(" ", "text"),
|
||
(4, 1): Cell("62", "text"),
|
||
}
|
||
)
|
||
|
||
assert sheet.row_cells(3) == [(1, Cell("60.01", "text")), (5, Cell(Decimal("10.5"), "number"))]
|
||
assert sheet.row_cells(7) == []
|
||
|
||
|
||
def test_row_of_spaces_is_empty() -> None:
|
||
sheet = _sheet({(2, 1): Cell(" ", "text"), (2, 4): Cell("\xa0 ", "text"), (3, 1): Cell(False, "bool")})
|
||
|
||
assert sheet.is_row_empty(2)
|
||
assert sheet.is_row_empty(10)
|
||
assert not sheet.is_row_empty(3)
|
||
|
||
|
||
def test_cell_text_strips_and_treats_spaces_as_empty() -> None:
|
||
assert Cell(" ", "text").text == ""
|
||
assert Cell(" Контрагент ", "text").text == "Контрагент"
|
||
assert Cell(None, "empty").text == ""
|
||
assert EMPTY_CELL.text == ""
|
||
assert Cell(Decimal("1250.00"), "number").text == "1250.00"
|
||
assert Cell(datetime.date(2024, 9, 30), "date").text == "2024-09-30"
|
||
|
||
|
||
def test_outline_defaults_to_zero() -> None:
|
||
sheet = _sheet({(1, 1): Cell("62", "text")}, row_outline={2: 1, 3: 2})
|
||
|
||
assert [sheet.outline(row) for row in (1, 2, 3)] == [0, 1, 2]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("index", "letter"),
|
||
[(1, "A"), (26, "Z"), (27, "AA"), (52, "AZ"), (703, "AAA"), (16_384, "XFD")],
|
||
)
|
||
def test_column_letter(index: int, letter: str) -> None:
|
||
assert column_letter(index) == letter
|
||
|
||
|
||
def test_column_letter_rejects_non_positive_index() -> None:
|
||
with pytest.raises(ValueError, match="1-based"):
|
||
column_letter(0)
|