test(render): этап 0 — golden-эталоны фидов как оракул эквивалентности (#27)
Docker Image CI / build (pull_request) Has been cancelled
Docker Image CI / build (pull_request) Has been cancelled
Первый этап эпика рефакторинга рендер-пайплайна (#34): снимает полные snapshot'ы выходов generate_channel_rss (RSS XML) и generate_channel_html (HTML) на записанном РЕАЛЬНОМ корпусе (4 канала: bladerunnerblues, embedoka, meow_design, theyforcedme) и закрепляет тестом сравнения. Оракул для всех последующих этапов: изменение байтов фида без ссылки на пункт реестра §3 спеки = регрессия. Никакой продакшн рендер-код не тронут — только тест-инфра. - tests/golden_replay.py: реплей-загрузчик (распикливает recorded/*.cache| *.chatinfo, monkeypatch tg_cache.cached_get_chat_history/cached_get_chat — буквально прод-путь cache-hit) + генератор goldens + нормализаторы. - tests/test_stage0_golden.py: оракул (11 тестов — RSS×4, HTML×4 + guards). - tests/test_data/golden/: 8 замороженных snapshot'ов (~1.9 МБ). - tests/conftest.py: пин TZ=UTC (naive-даты kurigram интерпретируются в локальной tz → без пина pubDate дрейфует между машинами). Нормализации (симметрично golden+actual, минимальные, каждая с источником недетерминизма): strip <lastBuildDate> (feedgen now()), strip <generator> (страховка), сортировка <div class="message-flags"> (merged-флаги — list(set(...)), hash-порядок; убирается коммитом этапа 2 по §3.8). Детерминизм подтверждён 3 способами: двойной in-process захват, разные PYTHONHASHSEED, регенерация в отдельном процессе — нормализованный вывод байт-идентичен. Полный сюит 332 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ suite order-independent regardless of collection order or invocation directory.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
if ROOT not in sys.path:
|
||||
@@ -15,3 +16,9 @@ if ROOT not in sys.path:
|
||||
import tests.mock_config as _mock_config
|
||||
|
||||
sys.modules['config'] = _mock_config
|
||||
|
||||
# Pin the runner timezone to UTC (stage-0 golden determinism, issue #27).
|
||||
# Naive kurigram dates are interpreted by datetime.timestamp() in the LOCAL tz, so
|
||||
# without this pin RSS <pubDate> values drift between machines (this sandbox is MSK).
|
||||
os.environ['TZ'] = 'UTC'
|
||||
time.tzset()
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# 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)
|
||||
# Merged-post flags are built as list(set(...)) (rss_generator.py:260-265): order depends on
|
||||
# PYTHONHASHSEED, which conftest cannot pin. Sort the flag tokens inside each flags div.
|
||||
# (This normalization is removed by the stage-2 commit referencing §3.8.)
|
||||
_FLAGS_DIV_RE = re.compile(r'<div class="message-flags">(.*?)</div>', re.DOTALL)
|
||||
|
||||
|
||||
def _sort_flags_div(match):
|
||||
flags = sorted(p.strip() for p in match.group(1).split("🏷") if p.strip())
|
||||
inner = " ".join("🏷 " + f for f in flags)
|
||||
return f'<div class="message-flags"> {inner} </div>'
|
||||
|
||||
|
||||
def normalize_rss(xml):
|
||||
xml = _LASTBUILDDATE_RE.sub("<lastBuildDate/>", xml)
|
||||
xml = _GENERATOR_RE.sub("<generator/>", xml)
|
||||
xml = _FLAGS_DIV_RE.sub(_sort_flags_div, xml)
|
||||
return xml
|
||||
|
||||
|
||||
def normalize_html(html):
|
||||
return _FLAGS_DIV_RE.sub(_sort_flags_div, 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()
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=missing-function-docstring, redefined-outer-name, line-too-long
|
||||
"""Stage-0 golden oracle (render-pipeline refactor epic, issue #27/#34).
|
||||
|
||||
Regenerates the RSS + HTML feeds for the frozen recorded corpus and asserts
|
||||
byte-equality against the committed goldens after the spec's declared normalizations
|
||||
(strip volatile <lastBuildDate>, sort the hash-ordered merged-flags div). Any other
|
||||
byte change = a render regression, and this test must catch it.
|
||||
|
||||
Guardrails: this stage only ADDS a loader + goldens + this test. No render/pipeline
|
||||
production code is touched; the goldens freeze CURRENT behavior including known bugs
|
||||
(fixed in later stages, each referencing a §3 registry item).
|
||||
|
||||
Regenerate goldens with: python -m tests.golden_replay
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from tests import golden_replay as gr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def golden_env(monkeypatch):
|
||||
"""Apply the determinism pins (signing key, time_based_merge, DB no-op). TZ=UTC is
|
||||
pinned process-wide in conftest."""
|
||||
gr.pin_environment(monkeypatch)
|
||||
return monkeypatch
|
||||
|
||||
|
||||
def _read_golden(channel, kind):
|
||||
with open(gr.golden_path(channel, kind), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel", gr.CORPUS_CHANNELS)
|
||||
def test_rss_golden(channel, golden_env):
|
||||
gr.patch_tg_cache(golden_env, channel)
|
||||
actual = gr.capture_rss(channel)
|
||||
expected = _read_golden(channel, "rss")
|
||||
assert gr.normalize_rss(actual) == gr.normalize_rss(expected), \
|
||||
f"RSS feed for {channel} diverged from the golden (render regression)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel", gr.CORPUS_CHANNELS)
|
||||
def test_html_golden(channel, golden_env):
|
||||
gr.patch_tg_cache(golden_env, channel)
|
||||
actual = gr.capture_html(channel)
|
||||
expected = _read_golden(channel, "html")
|
||||
assert gr.normalize_html(actual) == gr.normalize_html(expected), \
|
||||
f"HTML feed for {channel} diverged from the golden (render regression)"
|
||||
|
||||
|
||||
def test_all_goldens_present_and_nonempty():
|
||||
"""The corpus and its goldens must stay in lockstep — a missing/empty golden would
|
||||
silently pass the parametrized tests only if a channel were also dropped."""
|
||||
import os
|
||||
for channel in gr.CORPUS_CHANNELS:
|
||||
for kind in ("rss", "html"):
|
||||
path = gr.golden_path(channel, kind)
|
||||
assert os.path.exists(path), f"missing golden: {path}"
|
||||
assert os.path.getsize(path) > 0, f"empty golden: {path}"
|
||||
|
||||
|
||||
def test_normalization_sorts_merged_flags():
|
||||
"""Guard the load-bearing flag-sort normalization itself: merged-post flags are emitted
|
||||
as list(set(...)) (hash-ordered), so the normalizer must canonicalize their order."""
|
||||
unsorted = '<div class="message-flags"> 🏷 video 🏷 fwd 🏷 link </div>'
|
||||
resorted = '<div class="message-flags"> 🏷 link 🏷 fwd 🏷 video </div>'
|
||||
assert gr.normalize_html(unsorted) == gr.normalize_html(resorted)
|
||||
# ...and it is not a no-op that would let a real reordering slip through undetected.
|
||||
assert gr.normalize_html(unsorted) == '<div class="message-flags"> 🏷 fwd 🏷 link 🏷 video </div>'
|
||||
|
||||
|
||||
def test_normalization_strips_lastbuilddate():
|
||||
"""<lastBuildDate> is the one volatile RSS field (feedgen now() in the constructor)."""
|
||||
a = "<x><lastBuildDate>Mon, 06 Jul 2026 07:32:04 +0000</lastBuildDate><y/></x>"
|
||||
b = "<x><lastBuildDate>Mon, 06 Jul 2026 09:15:59 +0000</lastBuildDate><y/></x>"
|
||||
assert gr.normalize_rss(a) == gr.normalize_rss(b)
|
||||
Reference in New Issue
Block a user