All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m56s
CI Trade-In / backend-tests (pull_request) Successful in 5m25s
CI / backend-tests (pull_request) Successful in 18m18s
Почему: файлы в main были отформатированы старым ruff (хук 0.7.4), а после #3021 хук, CI и `uv run ruff format` — на 0.15.20. Без нормализации каждый коммит, касающийся одного из этих 161 файлов, тащил бы посторонние хунки на нетронутых строках (длинные assert-сообщения и т.п.). Что: `ruff format` 0.15.20 по backend/ и tradein-mvp/backend/; ничего, кроме форматирования (958+/1034−). `ruff check` 0.15.20 зелёный на обоих проектах, повторный `format --check` — 1001 файл без изменений (идемпотентно), compileall чистый. Refs #2864
111 lines
5.7 KiB
Python
111 lines
5.7 KiB
Python
"""Отрицательные формы не дают ОБРАТНЫЙ статус продажи (#2464).
|
||
|
||
`_STATUS_KW_RE` искал ключевые слова без защиты от отрицания, поэтому русские
|
||
отрицательные формы классифицировались наоборот:
|
||
|
||
«нереализована» → содержит «реализована» → sold
|
||
«не продана» → содержит «продана» → sold
|
||
«не забронирована» → содержит «забронирована» → reserved
|
||
«не в продаже» → содержит «в продаже» → free
|
||
|
||
То есть свободная квартира могла попасть в базу проданной. Отрицание теперь
|
||
гасит токен, а не переворачивает его: «не забронирована» не значит ни sold, ни
|
||
free, а «не продана → free» — вывод, а не факт со страницы. Лучше отсутствие
|
||
статуса, чем неверный.
|
||
|
||
Тесты идут через `parse_catalog_flat` (полный путь разбора HTML), а не только
|
||
через хелпер: инвертированный статус — это то, что доехало бы до БД.
|
||
|
||
Текущий эффект на проде — НУЛЕВОЙ, и это проверено, а не предположено:
|
||
`catalog_updated_at` пуст у всех 983 088 строк `domrf_kn_flats`, таска
|
||
`scrape_kn_catalog_flats` закомментирована в beat_schedule.py (WAF-cooldown).
|
||
Правка предупредительная: при включении пути дефект инвертировал бы статусы молча.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from app.services.scrapers.domrf_catalog import (
|
||
STATUS_FREE,
|
||
STATUS_RESERVED,
|
||
STATUS_SOLD,
|
||
parse_catalog_flat,
|
||
)
|
||
|
||
|
||
def _html_with_badge(badge_text: str, nav_text: str = "") -> str:
|
||
"""Тот же каркас, что в test_domrf_catalog_parse.py: nav + статус-бейдж."""
|
||
return (
|
||
"<!doctype html><html><body>"
|
||
f'<nav><a href="/catalog">Все квартиры</a><span>{nav_text}</span></nav>'
|
||
f'<div class="status-badge">{badge_text}</div>'
|
||
"<p>Площадь 45 м²</p>"
|
||
"</body></html>"
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"badge,было_бы",
|
||
[
|
||
("Нереализована", STATUS_SOLD),
|
||
("Не реализована", STATUS_SOLD),
|
||
("Не продана", STATUS_SOLD),
|
||
("Квартира не продана", STATUS_SOLD),
|
||
("Не забронирована", STATUS_RESERVED),
|
||
("Не в продаже", STATUS_FREE),
|
||
],
|
||
)
|
||
def test_negated_form_does_not_invert_status(badge: str, было_бы: str) -> None:
|
||
"""Головной: отрицание не должно давать статус, тем более противоположный.
|
||
|
||
На origin/main каждый случай возвращает `было_бы` — ровно обратное значение.
|
||
"""
|
||
result = parse_catalog_flat(_html_with_badge(badge))
|
||
got = result.get("status")
|
||
assert got != было_бы, (
|
||
f"«{badge}» классифицировано как {got!r} — отрицание перевёрнуто в свою "
|
||
"противоположность и уехало бы в domrf_kn_flats.status"
|
||
)
|
||
assert got is None, f"«{badge}» дало статус {got!r}; ожидали отсутствие статуса"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"badge,ожидаем",
|
||
[
|
||
("Продана", STATUS_SOLD),
|
||
("Продано", STATUS_SOLD),
|
||
("Реализована", STATUS_SOLD),
|
||
("Забронирована", STATUS_RESERVED),
|
||
("Бронь", STATUS_RESERVED),
|
||
("В продаже", STATUS_FREE),
|
||
("Свободна", STATUS_FREE),
|
||
],
|
||
)
|
||
def test_positive_forms_unchanged(badge: str, ожидаем: str) -> None:
|
||
"""Контроль от переусердствования: обычные формы распознаются как раньше.
|
||
|
||
Ловит «починку» через грубое гашение всего, что рядом со словом «не».
|
||
"""
|
||
result = parse_catalog_flat(_html_with_badge(badge))
|
||
assert result.get("status") == ожидаем, f"«{badge}» перестало распознаваться"
|
||
|
||
|
||
def test_negation_does_not_swallow_a_real_status_in_same_block() -> None:
|
||
"""Контроль: отрицание гасит СВОЙ токен, а не весь блок.
|
||
|
||
«Квартира не продана. Статус: в продаже» — настоящий статус в блоке есть.
|
||
Разбор по первому совпадению вернул бы None и потерял бы его.
|
||
"""
|
||
result = parse_catalog_flat(_html_with_badge("Квартира не продана. Статус: в продаже"))
|
||
assert result.get("status") == STATUS_FREE, (
|
||
f"настоящий статус в блоке потерян: {result.get('status')!r}"
|
||
)
|
||
|
||
|
||
def test_ne_inside_another_word_is_not_a_negation() -> None:
|
||
"""Контроль границы слова: «не» внутри другого слова не считается отрицанием."""
|
||
result = parse_catalog_flat(_html_with_badge("Цене снижена, квартира продана"))
|
||
assert result.get("status") == STATUS_SOLD, (
|
||
f"«не» в хвосте слова «Цене» съело настоящий статус: {result.get('status')!r}"
|
||
)
|