From be446482d083e1d336c66b87eac2b6f554349508 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 13:53:30 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=20?= =?UTF-8?q?=D0=97=D0=9E=D0=A3=D0=98=D0=A2=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=91=D1=82=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=D1=81=D1=8F=20=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=20=D1=82=D0=B8=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encumbrance.zouit_count приходит из parcels.py как len(zouit_rows) — число ЗАПИСЕЙ cad_zouit, пересёкших участок. Типы лежат отдельно, в zouit_types, и показаны строкой ниже. Оба экспортёра подписывали это число как «Кол-во типов ЗОУИТ». Расхождение не редкое — померил по реальным отчётам (analysis_runs): разборов с ЗОУИТ 1637 где записей != типов 717 (43.8%) среднее отношение 1.35x максимум по проду 7 записей при 3 типах То есть почти в половине отчётов с ЗОУИТ читателю показывали число, которое на треть больше того, что обещает подпись. Пункт эпика предлагал обратное — заменить значение на len(zouit_types). Это сделало бы фолбэк несогласованным с основным путём: у источника счётчик считает записи, и таким он приходит в чат-контекст (chat/retrieval.py) тоже. Врёт подпись, её и правлю. Тест формулирует инвариант по смыслу, а не сверкой с выбранной строкой: ЕСЛИ подпись обещает типы, показанное число обязано равняться числу типов. Такая формулировка переживёт разумное переименование и не даст «починить» тест подгонкой подписи. Проверяются оба формата — HTML и DOCX. Против origin/main (5 записей, 2 типа): html: подпись «Кол-во типов ЗОУИТ» показывает 5, типов 2 → падает docx: то же самое → падает число записей всё ещё в сводке — контроль, зелёный с обеих сторон перечисление типов на месте — контроль, зелёный с обеих сторон Контроли не для симметрии: первый ловит «починку», которая выкинула бы счётчик вместо переименования, второй — потерю перечисления типов. Прогоны: tests/services/exporters + tests/services/chat — 302 passed rc=0. Co-Authored-By: Claude Opus 5 --- .../services/exporters/full_report_docx.py | 8 +- .../services/exporters/full_report_html.py | 8 +- .../exporters/test_2464_zouit_count_label.py | 116 ++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 backend/tests/services/exporters/test_2464_zouit_count_label.py diff --git a/backend/app/services/exporters/full_report_docx.py b/backend/app/services/exporters/full_report_docx.py index 68171ea2..2860190d 100644 --- a/backend/app/services/exporters/full_report_docx.py +++ b/backend/app/services/exporters/full_report_docx.py @@ -298,7 +298,13 @@ def _build_zouit(doc: _DocxDocument, result: dict[str, Any]) -> None: summary_pairs: list[tuple[str, Any]] = [ ("Есть ЗОУИТ", has_zouit), - ("Кол-во типов ЗОУИТ", zouit_count), + # Подпись именно «Кол-во ЗОУИТ», а не «типов»: значение приходит из + # encumbrance.zouit_count, а там `len(zouit_rows)` — число ЗАПИСЕЙ cad_zouit, + # пересёкших участок (parcels.py). Типы лежат отдельно, в zouit_types, и + # показаны строкой ниже. Прежняя подпись «Кол-во типов ЗОУИТ» расходилась со + # значением в 717 разборах из 1637 с ЗОУИТ — 43.8%, в среднем завышая «типы» + # в 1.35 раза (#2464). + ("Кол-во ЗОУИТ", zouit_count), ] if zouit_types: summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types))) diff --git a/backend/app/services/exporters/full_report_html.py b/backend/app/services/exporters/full_report_html.py index e8d0f6ed..7ce3b393 100644 --- a/backend/app/services/exporters/full_report_html.py +++ b/backend/app/services/exporters/full_report_html.py @@ -538,7 +538,13 @@ def _build_zouit(result: dict[str, Any]) -> str: summary_pairs: list[tuple[str, Any]] = [ ("Есть ЗОУИТ", has_zouit), - ("Кол-во типов ЗОУИТ", zouit_count), + # Подпись именно «Кол-во ЗОУИТ», а не «типов»: значение приходит из + # encumbrance.zouit_count, а там `len(zouit_rows)` — число ЗАПИСЕЙ cad_zouit, + # пересёкших участок (parcels.py). Типы лежат отдельно, в zouit_types, и + # показаны строкой ниже. Прежняя подпись «Кол-во типов ЗОУИТ» расходилась со + # значением в 717 разборах из 1637 с ЗОУИТ — 43.8%, в среднем завышая «типы» + # в 1.35 раза (#2464). + ("Кол-во ЗОУИТ", zouit_count), ] if zouit_types: summary_pairs.append(("Типы", ", ".join(str(t) for t in zouit_types))) diff --git a/backend/tests/services/exporters/test_2464_zouit_count_label.py b/backend/tests/services/exporters/test_2464_zouit_count_label.py new file mode 100644 index 00000000..ec17ce91 --- /dev/null +++ b/backend/tests/services/exporters/test_2464_zouit_count_label.py @@ -0,0 +1,116 @@ +"""Подпись к числу ЗОУИТ не должна обещать «типы», когда там записи (#2464). + +`encumbrance.zouit_count` приходит из `parcels.py` как `len(zouit_rows)` — число ЗАПИСЕЙ +`cad_zouit`, пересёкших участок. Типы лежат отдельно, в `zouit_types`. Оба экспортёра +подписывали это число как «Кол-во типов ЗОУИТ». + +Расхождение не редкое: из 1637 разборов с ЗОУИТ в 717 (43.8 %) число записей не равно +числу типов, в среднем завышая «типы» в 1.35 раза (максимум по проду — 7 записей при +3 типах). + +Инвариант проверяется по смыслу, а не сверкой с выбранной строкой: **если подпись обещает +типы, показанное число обязано равняться числу типов**. Такая формулировка переживёт любое +разумное переименование и не даст «починить» тест подгонкой подписи. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import re +from typing import Any + +import pytest + +# Пять записей ЗОУИТ, но всего два различных типа — ровно тот случай, где подпись врала. +_OVERLAPS: list[dict[str, Any]] = [ + {"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №1", "coverage_pct": 12.0}, + {"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №2", "coverage_pct": 8.0}, + {"type_zone": "Охранная зона ЛЭП", "name": "ВЛ-110 №3", "coverage_pct": 3.0}, + {"type_zone": "Приаэродромная территория", "name": "ПАТ подзона 3", "coverage_pct": 100.0}, + {"type_zone": "Приаэродромная территория", "name": "ПАТ подзона 4", "coverage_pct": 100.0}, +] +_RESULT: dict[str, Any] = { + "encumbrance": { + "has_zouit": True, + "zouit_count": 5, + "zouit_types": ["Охранная зона ЛЭП", "Приаэродромная территория"], + }, + "nspd_zouit_overlaps": _OVERLAPS, +} + +_RECORDS = 5 +_TYPES = 2 + + +def _html_pairs() -> list[tuple[str, str]]: + """KV-пары из HTML-блока ЗОУИТ: [(подпись, значение), …].""" + from app.services.exporters.full_report_html import _build_zouit + + out = _build_zouit(dict(_RESULT)) + return [ + (re.sub(r"<[^>]+>", "", k).strip(), re.sub(r"<[^>]+>", "", v).strip()) + for k, v in re.findall(r"\s*(.*?)\s*(.*?)\s*", out, re.S) + ] or [ + (re.sub(r"<[^>]+>", "", k).strip(), re.sub(r"<[^>]+>", "", v).strip()) + for k, v in re.findall( + r"\s*]*>(.*?)\s*]*>(.*?)\s*", out, re.S + ) + ] + + +def _docx_pairs() -> list[tuple[str, str]]: + """KV-пары из DOCX-блока ЗОУИТ.""" + from docx import Document + + from app.services.exporters.full_report_docx import _build_zouit + + doc = Document() + _build_zouit(doc, dict(_RESULT)) + pairs: list[tuple[str, str]] = [] + for table in doc.tables: + for row in table.rows: + cells = [c.text.strip() for c in row.cells] + if len(cells) == 2: + pairs.append((cells[0], cells[1])) + return pairs + + +@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")]) +def test_label_promising_types_must_show_types(pairs_fn: Any, fmt: str) -> None: + """Подпись со словом «тип» обязана нести число типов, а не записей.""" + pairs = pairs_fn() + assert pairs, f"{fmt}: не разобрал ни одной KV-пары — тест ослеп" + + for label, value in pairs: + if "тип" not in label.lower(): + continue + digits = re.fullmatch(r"\d+", value.strip()) + if not digits: + continue # строка «Типы: A, B» — это перечисление, а не счётчик + assert int(value) == _TYPES, ( + f"{fmt}: подпись {label!r} обещает типы, а показывает {value} — " + f"это число ЗАПИСЕЙ ({_RECORDS}), типов же {_TYPES}" + ) + + +@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")]) +def test_record_count_is_still_shown(pairs_fn: Any, fmt: str) -> None: + """Контроль: само число записей из отчёта не пропало — подпись правится, не значение.""" + pairs = pairs_fn() + counters = [(k, v) for k, v in pairs if re.fullmatch(r"\d+", v.strip())] + assert any( + int(v) == _RECORDS for _, v in counters + ), f"{fmt}: число ЗОУИТ-записей ({_RECORDS}) исчезло из сводки: {counters}" + + +@pytest.mark.parametrize("pairs_fn,fmt", [(_html_pairs, "html"), (_docx_pairs, "docx")]) +def test_types_are_still_listed(pairs_fn: Any, fmt: str) -> None: + """Контроль: перечисление типов на месте — читатель по-прежнему видит, какие они.""" + pairs = pairs_fn() + joined = " ".join(f"{k} {v}" for k, v in pairs) + assert ( + "Охранная зона ЛЭП" in joined and "Приаэродромная территория" in joined + ), f"{fmt}: типы ЗОУИТ пропали из сводки"