# -*- coding: utf-8 -*- """Тесты граничных случаев и обработки исключений. Запуск: pytest -q (из корня проекта) """ from __future__ import annotations import datetime import sys from pathlib import Path import pytest from odf.opendocument import OpenDocumentSpreadsheet, load from odf.style import Style, TableRowProperties from odf.table import Table, TableCell, TableRow from odf.text import P ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from priem_register.mapping import discover_sources # noqa: E402 from priem_register.models import Record # noqa: E402 from priem_register.readers import get_reader # noqa: E402 from priem_register.readers.base import SourceReader # noqa: E402 from priem_register.writers import JournalWriter, RegisterWriter # noqa: E402 # --- Record ------------------------------------------------------------- def test_record_value_out_of_range(): r = Record(values=["a", "b"]) assert r.value(1) == "a" # колонка B assert r.value(2) == "b" # колонка C assert r.value(12) is None # колонка M отсутствует -> None assert r.value(99) is None # --- get_reader --------------------------------------------------------- def test_get_reader_unknown_extension(tmp_path): bad = tmp_path / "file.txt" bad.write_text("x") with pytest.raises(ValueError): get_reader(bad) # --- mapping ------------------------------------------------------------ def test_discover_missing_dir(tmp_path): with pytest.raises(FileNotFoundError): discover_sources(tmp_path / "нет") def test_discover_empty_dir(tmp_path): assert discover_sources(tmp_path) == [] def test_discover_ignores_non_matching(tmp_path): (tmp_path / "ТО1").mkdir() (tmp_path / "ТО1" / "заметка.txt").write_text("x") (tmp_path / "ТО1" / "отчёт_74_99.xlsx").write_bytes(b"") # нет даты (tmp_path / "ТО1" / "09.04.2026_99_25.docx").write_bytes(b"") # чужой формат assert discover_sources(tmp_path) == [] def test_discover_invalid_date_raises_clear_error(tmp_path): (tmp_path / "ТО1").mkdir() (tmp_path / "ТО1" / "32.04.2026_74_99.xlsx").write_bytes(b"") # дня 32 нет with pytest.raises(ValueError, match="Некорректная дата"): discover_sources(tmp_path) def test_discover_matches_and_sorts(tmp_path): d = tmp_path / "ТО2" d.mkdir() (d / "21.04.2026_74_03.xlsx").write_bytes(b"") (d / "03.04.2026_74_03.xlsx").write_bytes(b"") found = discover_sources(tmp_path) assert [s.name for s in found] == ["03.04.2026_74_03", "21.04.2026_74_03"] assert all(s.sheet == "ТО2_03" for s in found) assert found[0].date < found[1].date def test_discover_file_without_to_dir(tmp_path): (tmp_path / "09.04.2026_74_25.ods").write_bytes(b"") with pytest.raises(ValueError): discover_sources(tmp_path) # --- RegisterWriter на синтетическом .ods ------------------------------- def _make_register(tmp_path, sheet_name="ТО1_25", numbers=(1, 2), trailing_block=True): doc = OpenDocumentSpreadsheet() rs = Style(name="ro1", family="table-row") rs.addElement(TableRowProperties(rowheight="0.45cm")) doc.automaticstyles.addElement(rs) table = Table(name=sheet_name) # шапка head = TableRow() head.addElement(_p_cell("№ п/п")) table.addElement(head) # строки данных for n in numbers: row = TableRow(stylename="ro1") row.addElement(_float_cell(n)) for _ in range(12): row.addElement(TableCell()) table.addElement(row) if trailing_block: empty = TableRow(numberrowsrepeated="100") empty.addElement(TableCell(numbercolumnsrepeated="13")) table.addElement(empty) doc.spreadsheet.addElement(table) path = tmp_path / "reg.ods" doc.save(str(path)) return path def _p_cell(text): c = TableCell(valuetype="string") c.addElement(P(text=text)) return c def _float_cell(n): c = TableCell(valuetype="float", value=str(n)) c.addElement(P(text=str(n))) return c def _read_numbers(path, sheet): doc = load(str(path)) table = [t for t in doc.spreadsheet.getElementsByType(Table) if t.getAttribute("name") == sheet][0] nums = [] for tr in table.getElementsByType(TableRow): if tr.getAttribute("numberrowsrepeated"): continue cells = tr.getElementsByType(TableCell) if cells and cells[0].getAttribute("value"): nums.append(int(float(cells[0].getAttribute("value")))) return nums def test_register_missing_sheet(tmp_path): path = _make_register(tmp_path) w = RegisterWriter(path) assert w.has_sheet("ТО1_25") assert not w.has_sheet("НЕТ") def test_register_no_numbered_rows(tmp_path): doc = OpenDocumentSpreadsheet() table = Table(name="ТО1_25") head = TableRow() head.addElement(_p_cell("№ п/п")) table.addElement(head) doc.spreadsheet.addElement(table) path = tmp_path / "empty.ods" doc.save(str(path)) with pytest.raises(ValueError): RegisterWriter(path).append("ТО1_25", [Record(["a"])]) def test_register_append_continues_numbering(tmp_path): path = _make_register(tmp_path, numbers=(1, 2, 3)) w = RegisterWriter(path) n = w.append("ТО1_25", [Record(["x"]), Record(["y"])]) out = tmp_path / "out.ods" w.save(out) assert n == 2 assert _read_numbers(out, "ТО1_25") == [1, 2, 3, 4, 5] def test_register_append_empty_records(tmp_path): path = _make_register(tmp_path, numbers=(1, 2)) w = RegisterWriter(path) assert w.append("ТО1_25", []) == 0 out = tmp_path / "out.ods" w.save(out) assert _read_numbers(out, "ТО1_25") == [1, 2] def test_register_template_is_last_element(tmp_path): # нет хвостового пустого блока -> образец является последним элементом path = _make_register(tmp_path, numbers=(1, 2), trailing_block=False) w = RegisterWriter(path) w.append("ТО1_25", [Record(["x"]), Record(["y"])]) out = tmp_path / "out.ods" w.save(out) assert _read_numbers(out, "ТО1_25") == [1, 2, 3, 4] def test_register_preserves_order_and_types(tmp_path): path = _make_register(tmp_path, numbers=(10,)) w = RegisterWriter(path) rec = Record(["Адрес", "MFC-1", "объект", 5, datetime.date(2026, 4, 15)]) w.append("ТО1_25", [rec]) out = tmp_path / "out.ods" w.save(out) doc = load(str(out)) table = [t for t in doc.spreadsheet.getElementsByType(Table) if t.getAttribute("name") == "ТО1_25"][0] last = [tr for tr in table.getElementsByType(TableRow) if not tr.getAttribute("numberrowsrepeated")][-1] cells = last.getElementsByType(TableCell) assert cells[0].getAttribute("value") == "11" # № продолжен assert cells[4].getAttribute("valuetype") == "float" # кол-во assert cells[5].getAttribute("valuetype") == "date" # дата assert cells[5].getAttribute("datevalue") == "2026-04-15" # --- JournalWriter ------------------------------------------------------ def test_journal_writes_under_header_and_clears_examples(tmp_path): import openpyxl wb = openpyxl.Workbook() ws = wb.active ws.append(["Дата внесения", "Файл внесения", "Количество записей"]) ws.append(["Например", None, None]) ws.append([datetime.date(2026, 4, 22), "пример_файл", 99]) path = tmp_path / "j.xlsx" wb.save(path) from priem_register.models import JournalEntry w = JournalWriter(path) w.write([JournalEntry(datetime.date(2026, 6, 8), "файл_1", 5)]) out = tmp_path / "jout.xlsx" w.save(out) wb2 = openpyxl.load_workbook(out) ws2 = wb2.active # шапка на месте assert ws2.cell(row=1, column=1).value == "Дата внесения" # реальная запись сразу под шапкой (строка 2), а не после примера assert ws2.cell(row=2, column=2).value == "файл_1" assert ws2.cell(row=2, column=3).value == 5 # строки-примеры затёрты assert ws2.cell(row=3, column=2).value is None assert ws2.cell(row=3, column=3).value is None # --- аномалии чтения: данные не теряются молча --------------------------- class _FakeReader(SourceReader): def __init__(self, rows): super().__init__("fake.xlsx") self._fake = rows def _rows(self): return self._fake def test_reader_enters_all_rows_from_second(): # задание: «внести все строки, начиная со второй» — без фильтра по № rows = [ ["№ п/п", "Адрес"], # шапка — пропускается [1, "Адрес", "MFC-1"], # запись с № [None, None, None, None, None, None, None, None, None, None, "08.04", "покупатель"], # строка без № — тоже запись ] res = _FakeReader(rows).read() assert len(res.records) == 2 assert res.records[0].value(2) == "MFC-1" assert res.records[1].value(10) == "08.04" # Дата выдачи assert res.records[1].value(11) == "покупатель" # Примечание assert res.warnings == [] assert res.notes == [] def test_reader_extra_columns_merged_into_note(): rows = [ ["№ п/п"], [1] + ["v%d" % i for i in range(1, 12)] + ["реестр", "ХВОСТ"], # 14 колонок ] res = _FakeReader(rows).read() assert len(res.records) == 1 assert res.records[0].value(12) == "реестр" # колонка M на месте assert res.records[0].value(11).endswith("ХВОСТ") # N ушёл в Примечание assert res.warnings == [] assert len(res.notes) == 1 def test_reader_skips_header_and_empty_rows(): rows = [ ["№ п/п"], # шапка [1, "a"], # запись [None, None, None], # пусто — пропускается ["", "", ""], # пусто — пропускается [2, "b"], # запись ] res = _FakeReader(rows).read() assert len(res.records) == 2 assert res.warnings == [] assert res.notes == [] def test_reader_header_not_flagged(): rows = [["№ п/п", "Адрес", "Рег"]] # только шапка res = _FakeReader(rows).read() assert res.records == [] assert res.warnings == [] # --- OdsReader: текст внутри span/кратные пробелы не теряется ------------ def test_ods_reader_extracts_span_and_spaces(tmp_path): from odf.opendocument import OpenDocumentSpreadsheet from odf.table import Table as OTable, TableCell as OCell, TableRow as ORow from odf.text import P as OP, S as OS, Span as OSpan from priem_register.readers.ods_reader import OdsReader doc = OpenDocumentSpreadsheet() table = OTable(name="ТО1_25") # шапка h = ORow(); hc = OCell(valuetype="string"); hc.addElement(OP(text="№ п/п")) h.addElement(hc); table.addElement(h) # строка данных: объект с span и тройным пробелом r = ORow() c0 = OCell(valuetype="float", value="1"); c0.addElement(OP(text="1")) r.addElement(c0) p = OP(text="74:25:1 ") p.addElement(OS(c=3)) # три неразрывных пробела ODF p.addElement(OSpan(text="ЗЛАТОУСТ")) # текст внутри span cell = OCell(valuetype="string"); cell.addElement(p) r.addElement(cell) table.addElement(r) doc.spreadsheet.addElement(table) path = tmp_path / "spans.ods" doc.save(str(path)) res = OdsReader(path).read() assert len(res.records) == 1 assert res.records[0].value(1) == "74:25:1 ЗЛАТОУСТ" # span + 3 пробела (колонка B) # --- интеграционные тесты на реальных данных ---------------------------- def test_full_pipeline_real_data(tmp_path): from priem_register import Pipeline reg_out = tmp_path / "reg_out.ods" jour_out = tmp_path / "jour_out.xlsx" p = Pipeline(register_out=reg_out, journal_out=jour_out) res = p.run() assert res.total == 523 assert len(res.entries) == 12 assert reg_out.exists() and jour_out.exists() assert all(e.count > 0 for e in res.entries) assert res.warnings == [] # все непустые строки внесены (включая «уведомление»/«покупатель» как записи) counts = {e.file_name: e.count for e in res.entries} assert counts["09.04.2026_74_34"] == 66 # 62 + 4×уведомление assert counts["17.04.2026_74_25"] == 46 # 45 + покупатель # единственная заметка — данные за колонкой M (закладная) в «Примечание» assert len(res.notes) == 1 assert "закладная" in res.notes[0] def test_pipeline_idempotent(tmp_path): from priem_register import Pipeline totals = [] for i in range(2): p = Pipeline(register_out=tmp_path / ("r%d.ods" % i), journal_out=tmp_path / ("j%d.xlsx" % i)) totals.append(p.run().total) assert totals[0] == totals[1] == 523 if __name__ == "__main__": sys.exit(pytest.main([__file__, "-q"]))