Merge pull request 'test(render): этап 0 — golden-эталоны фидов (оракул) (#27)' (#35) from refactor/render-stage0-golden into main
Docker Image CI / build (push) Has been cancelled

Reviewed-on: #35
This commit was merged in pull request #35.
This commit is contained in:
2026-07-06 17:05:10 +03:00
11 changed files with 10670 additions and 0 deletions
+7
View File
@@ -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()
+187
View File
@@ -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
+77
View File
@@ -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)