Files
pyrogram-bridge/tests/golden_replay.py
T
agent_coder d8af0aeb44
Docker Image CI / build (pull_request) Has been cancelled
refactor(render): этап 2 — футер merged-групп из настоящего Message, выпил конвертера (#29)
Убрал обратный ход данных: merged-группы больше не конвертируют отрендеренный
dict обратно в мок-Message (processed_message_to_tg_message удалён, −84 строки).
Футер рендерится прямо из реального главного Message группы. main_message (dict)
по-прежнему выбирается тем же критерием-индексом → title/date/author байт-в-байт.

Реестр §3:
- §3.6 custom-emoji реакции merged-футера — отдельный span на каждую (через
  реальный _reactions_views_links), а не агрегированная « N»;
- §3.7 дата merged-футера из naive-local реального Message (не UTC-мок); в
  UTC-голдене не видна → выделенный не-UTC тест (TZ=Europe/Moscow);
- §3.8 порядок флагов merged-поста детерминирован (first-seen через
  dict.fromkeys вместо hash-ordered list(set)); временная сортировка флагов
  снята из оракула (golden_replay нормализатор убран, normalize_html теперь
  identity); проверено — оракул стабилен на PYTHONHASHSEED 0/1/42;
- §3.15 пустой reactions-объект больше не даёт ведущий разделитель (и одиночные).

Golden обновлён строго по §3: span +2 (§3.6 сплит custom-реакции), reorder
флагов только на merged-постах (§3.8), снят ведущий разделитель на одиночном
poll-посте (§3.15). Оракул зелёный и детерминированный (2 прогона идентичны).
Полный сюит 353 passed (+6 тестов этапа 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:39:49 +03:00

182 lines
8.0 KiB
Python

# flake8: noqa
# pylint: disable=import-outside-toplevel, missing-function-docstring, line-too-long
"""Stage-0 golden-baseline replay helpers (render-pipeline refactor epic, issue #27/#34).
Replays the frozen recorded corpus (tests/test_data/recorded/) through the REAL
cache-hit path and captures the full generate_channel_rss / generate_channel_html
output as an equivalence oracle for every later refactor stage. NO production render
code is touched here — only a test loader + determinism pins.
The recorded corpus was written by the prod bridge cache (tg_cache._save_*_to_cache):
{channel}.cache -> {'timestamp', 'limit', 'messages': List[Message]}
{channel}.chatinfo -> {'timestamp', 'data': {'id', 'title', 'username'}}
`timestamp` / `limit` are ignored (no freshness check) — this is literally the prod
cache-hit payload the renderer sees in production.
Run `python -m tests.golden_replay` from the repo root to (re)generate the goldens.
"""
import os
import re
import time
import pickle
from types import SimpleNamespace
TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(TESTS_DIR)
RECORDED_DIR = os.path.join(TESTS_DIR, "test_data", "recorded")
GOLDEN_DIR = os.path.join(TESTS_DIR, "test_data", "golden")
# Corpus channels frozen on `main` (spec "Этап 0"). exclude_flags / exclude_text
# scenarios are intentionally NOT in golden: filters do not change the bytes of the
# surviving posts; their membership/regex semantics are covered by dedicated unit tests.
CORPUS_CHANNELS = ["bladerunnerblues", "embedoka", "meow_design", "theyforcedme"]
# Recorded corpus is 100 messages/channel; the feed cap is 200. limit=100 exercises the
# whole corpus (grouping only reduces the post count below the message count).
GOLDEN_LIMIT = 100
# Fixed media-URL signing key so a golden captured on any machine/checkout reproduces on
# regeneration (a fresh checkout otherwise mints a new secrets.token_hex and every URL
# digest changes). Used identically by the generator and the comparison test.
GOLDEN_SIGNING_KEY = "stage0-golden-fixed-signing-key-0000000000000000"
# --------------------------------------------------------------------------- #
# Replay loader — the literal prod cache-hit path.
# --------------------------------------------------------------------------- #
def load_recorded(channel):
"""Unpickle a recorded {channel}.cache / {channel}.chatinfo pair.
Returns (messages: List[Message], chatinfo_data: dict). timestamp/limit ignored."""
with open(os.path.join(RECORDED_DIR, f"{channel}.cache"), "rb") as f:
cache = pickle.load(f)
with open(os.path.join(RECORDED_DIR, f"{channel}.chatinfo"), "rb") as f:
chatinfo = pickle.load(f)
return cache["messages"], chatinfo["data"]
def patch_tg_cache(monkeypatch, channel):
"""Monkeypatch tg_cache.cached_get_chat_history / cached_get_chat to return the
recorded objects for `channel`. The feed functions lazy-import tg_cache, so patching
the module resolves late and works (mirrors test_stage4_eventloop.py)."""
messages, chatinfo = load_recorded(channel)
async def fake_get_chat_history(client, channel_id, limit=20):
return messages
async def fake_get_chat(client, channel_id):
# cached_get_chat returns SimpleNamespace(**data) with .id/.title/.username.
return SimpleNamespace(**chatinfo)
monkeypatch.setattr("tg_cache.cached_get_chat_history", fake_get_chat_history, raising=False)
monkeypatch.setattr("tg_cache.cached_get_chat", fake_get_chat, raising=False)
def pin_environment(monkeypatch):
"""Apply the spec's non-TZ determinism pins (TZ=UTC is pinned globally in conftest /
the __main__ bootstrap so both the test runner and the generator agree)."""
import post_parser
import rss_generator
from url_signer import KeyManager
# Pin the media-URL signing key (see GOLDEN_SIGNING_KEY).
monkeypatch.setattr(KeyManager, "signing_key", GOLDEN_SIGNING_KEY)
# time_based_merge=True so the meow_design time-cluster core is actually exercised.
# The flag is read from rss_generator.Config at call time; post_parser.Config is a
# sibling dict from the same get_settings() — pin both (cheap insurance vs. drift).
monkeypatch.setitem(rss_generator.Config, "time_based_merge", True)
monkeypatch.setitem(post_parser.Config, "time_based_merge", True)
# No media-id DB side effect outside tests/ (byte-neutral for the feed, but the write
# to ./data/media_file_ids.db is a forbidden side effect). upsert is imported INTO the
# post_parser namespace, so patch it there.
monkeypatch.setattr(post_parser, "upsert_media_file_ids_bulk_sync", lambda *a, **k: None)
# --------------------------------------------------------------------------- #
# Capture.
# --------------------------------------------------------------------------- #
def capture_rss(channel):
import asyncio
from rss_generator import generate_channel_rss
return asyncio.run(generate_channel_rss(channel, client=SimpleNamespace(), limit=GOLDEN_LIMIT))
def capture_html(channel):
import asyncio
from rss_generator import generate_channel_html
return asyncio.run(generate_channel_html(channel, client=SimpleNamespace(), limit=GOLDEN_LIMIT))
# --------------------------------------------------------------------------- #
# Normalizations (applied SYMMETRICALLY to golden and actual before comparison).
# Only the spec-sanctioned set — over-normalizing is exactly how a regression hides.
# --------------------------------------------------------------------------- #
# feedgen sets <lastBuildDate> to now() once in the FeedGenerator constructor: stable
# within a process, but changes between capture runs — regex it out on both sides.
_LASTBUILDDATE_RE = re.compile(r"<lastBuildDate>.*?</lastBuildDate>", re.DOTALL)
# feedgen 1.0.0 emits no <generator>; normalized anyway as cheap insurance vs. a lib upgrade.
_GENERATOR_RE = re.compile(r"<generator>.*?</generator>", re.DOTALL)
# NOTE: the stage-0 flag-sort normalization (_FLAGS_DIV_RE / _sort_flags_div) was
# removed in stage 2 (§3.8). Merged-post flags are now built in deterministic
# first-seen order (rss_generator._render_messages_groups: dict.fromkeys(...)),
# so the golden stores the real order and no normalization is needed — keeping it
# would only mask a real flag-order regression.
def normalize_rss(xml):
xml = _LASTBUILDDATE_RE.sub("<lastBuildDate/>", xml)
xml = _GENERATOR_RE.sub("<generator/>", xml)
return xml
def normalize_html(html):
return html
def golden_path(channel, kind):
ext = {"rss": "rss.xml", "html": "feed.html"}[kind]
return os.path.join(GOLDEN_DIR, f"{channel}.{ext}")
# --------------------------------------------------------------------------- #
# Generator entry point: `python -m tests.golden_replay`
# --------------------------------------------------------------------------- #
def _bootstrap_standalone():
"""Reproduce the conftest bootstrap for the standalone generator: repo root on the
path, mocked config, UTC timezone."""
import sys
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
import tests.mock_config as _mock_config
sys.modules["config"] = _mock_config
os.environ["TZ"] = "UTC"
time.tzset()
def generate_all():
from _pytest.monkeypatch import MonkeyPatch
os.makedirs(GOLDEN_DIR, exist_ok=True)
for channel in CORPUS_CHANNELS:
mp = MonkeyPatch()
try:
pin_environment(mp)
patch_tg_cache(mp, channel)
rss = capture_rss(channel)
html = capture_html(channel)
finally:
mp.undo()
with open(golden_path(channel, "rss"), "w", encoding="utf-8") as f:
f.write(rss)
with open(golden_path(channel, "html"), "w", encoding="utf-8") as f:
f.write(html)
print(f"{channel}: rss={len(rss)}B items={rss.count('<item>')} "
f"html={len(html)}B posts={html.count('message-body') if html else 0}")
if __name__ == "__main__":
_bootstrap_standalone()
generate_all()