test(render): этап 0 — golden-эталоны фидов (оракул) (#27) #35

Merged
vvzvlad merged 1 commits from refactor/render-stage0-golden into main 2026-07-06 17:05:11 +03:00
Collaborator

Summary

Этап 0/6 эпика рефакторинга рендер-пайплайна (#34, closes #27): golden-эталоны фидов как оракул эквивалентности, снятые ДО любых правок кода. Полные snapshot'ы generate_channel_rss (RSS XML) + generate_channel_html (HTML) на записанном РЕАЛЬНОМ корпусе (4 канала) закреплены тестом сравнения. Для всех следующих этапов: изменение байтов фида без ссылки на §3 реестра спеки = регрессия. Продакшн рендер-код не тронут — только тест-инфра.

Что вошло

  • tests/golden_replay.py — реплей-загрузчик (распикливает recorded/*.cache|*.chatinfo, monkeypatch tg_cache.cached_get_chat_history/cached_get_chat — буквально прод-путь cache-hit, образец test_stage4_eventloop.py) + генератор goldens + нормализаторы.
  • tests/test_stage0_golden.py — оракул: 11 тестов (RSS×4, HTML×4 + 3 guard'а на нормализаторы/наличие goldens).
  • tests/test_data/golden/ — 8 замороженных snapshot'ов (~1.9 МБ; item/post: bladerunnerblues 90, embedoka 84, meow_design 36 merged, theyforcedme 89).
  • tests/conftest.py — пин TZ=UTC (naive-даты kurigram интерпретируются datetime.timestamp() в ЛОКАЛЬНОЙ tz → без пина <pubDate> дрейфует; песочница — MSK).

Нормализации (минимальные, симметрично golden+actual, каждая с источником недетерминизма)

  • strip <lastBuildDate> (feedgen ставит now() в конструкторе);
  • strip <generator> (feedgen 1.0.0 не эмитит — страховка от апгрейда либы);
  • сортировка токенов <div class="message-flags"> (merged-посты строят флаги как list(set(...)), hash-порядок; убирается коммитом этапа 2 по §3.8).

Сознательно НЕ добавлял нормализаций сверх этих (реакции, например, приходят фиксированного порядка из пикла — 0 дрейфа). Детерминизм подтверждён 3 способами: двойной in-process захват, разные PYTHONHASHSEED, регенерация в ОТДЕЛЬНОМ процессе → нормализованный вывод байт-идентичен по всем 8 файлам.

How verified

  • Env: python3.13 -m venv .venv + pip install -r requirements.txt (kurigram 2.2.23 и пр.) — без блокеров. Все 4 пары распикливаются под 2.2.23.
  • test_stage0_golden.py: 11 passed. Полный сюит: 332 passed. Прогнал независимо.

⚠️ Два judgment call'а — нужен твой ack (это оракул, важно зафиксировать «правильную» базу)

  1. time_based_merge=True при захвате. В параграфе про snapshot это прямо не сказано, но чеклист детерминизма спеки это провизионит («фикстура time-based кластера требует time_based_merge=True»), а канал meow_design существует именно как time-cluster ядро — захват с ним OFF оставил бы весь этот путь непокрытым оракулом. Включил единообразно (пин и rss_generator.Config, и post_parser.Config). Если хотел дефолт (False) — скажи, перезахвачу.
  2. Reply-обогащение (HTML-only) не замокано. _reply_enrichment делает ЖИВОЙ client.get_messages — вне cache-hit пути, что мокает спека. С голым тест-клиентом оно стабильно no-op'ит (ошибка логируется/глотается) и падает обратно на записанный shallow reply_to_message из пикла. Детерминировано и верно «мокаем только tg_cache»; синтетические reply-таргеты НЕ фабриковал (это инъекция в оракул против guardrail'а «заморозить текущее поведение»). RSS не затронут.

Checklist

  • этап 0 первым, до правок кода; продакшн рендер не тронут
  • корпус реальный (записанные сообщения), реплей = прод-путь cache-hit
  • оракул зелёный + детерминизм подтверждён; нормализации минимальны и обоснованы
  • ack по двум judgment call'ам выше
## Summary Этап 0/6 эпика рефакторинга рендер-пайплайна (#34, closes #27): golden-эталоны фидов как **оракул эквивалентности**, снятые ДО любых правок кода. Полные snapshot'ы `generate_channel_rss` (RSS XML) + `generate_channel_html` (HTML) на записанном РЕАЛЬНОМ корпусе (4 канала) закреплены тестом сравнения. Для всех следующих этапов: изменение байтов фида без ссылки на §3 реестра спеки = регрессия. **Продакшн рендер-код не тронут** — только тест-инфра. ## Что вошло - `tests/golden_replay.py` — реплей-загрузчик (распикливает `recorded/*.cache|*.chatinfo`, monkeypatch `tg_cache.cached_get_chat_history`/`cached_get_chat` — буквально прод-путь cache-hit, образец `test_stage4_eventloop.py`) + генератор goldens + нормализаторы. - `tests/test_stage0_golden.py` — оракул: 11 тестов (RSS×4, HTML×4 + 3 guard'а на нормализаторы/наличие goldens). - `tests/test_data/golden/` — 8 замороженных snapshot'ов (~1.9 МБ; item/post: bladerunnerblues 90, embedoka 84, meow_design 36 merged, theyforcedme 89). - `tests/conftest.py` — пин `TZ=UTC` (naive-даты kurigram интерпретируются `datetime.timestamp()` в ЛОКАЛЬНОЙ tz → без пина `<pubDate>` дрейфует; песочница — MSK). ## Нормализации (минимальные, симметрично golden+actual, каждая с источником недетерминизма) - strip `<lastBuildDate>` (feedgen ставит `now()` в конструкторе); - strip `<generator>` (feedgen 1.0.0 не эмитит — страховка от апгрейда либы); - сортировка токенов `<div class="message-flags">` (merged-посты строят флаги как `list(set(...))`, hash-порядок; убирается коммитом этапа 2 по §3.8). Сознательно НЕ добавлял нормализаций сверх этих (реакции, например, приходят фиксированного порядка из пикла — 0 дрейфа). Детерминизм подтверждён 3 способами: двойной in-process захват, разные `PYTHONHASHSEED`, регенерация в ОТДЕЛЬНОМ процессе → нормализованный вывод байт-идентичен по всем 8 файлам. ## How verified - Env: `python3.13 -m venv .venv` + `pip install -r requirements.txt` (kurigram 2.2.23 и пр.) — без блокеров. Все 4 пары распикливаются под 2.2.23. - `test_stage0_golden.py`: **11 passed**. Полный сюит: **332 passed**. Прогнал независимо. ## ⚠️ Два judgment call'а — нужен твой ack (это оракул, важно зафиксировать «правильную» базу) 1. **`time_based_merge=True` при захвате.** В параграфе про snapshot это прямо не сказано, но чеклист детерминизма спеки это провизионит («фикстура time-based кластера требует `time_based_merge=True`»), а канал `meow_design` существует именно как time-cluster ядро — захват с ним OFF оставил бы весь этот путь непокрытым оракулом. Включил единообразно (пин и `rss_generator.Config`, и `post_parser.Config`). Если хотел дефолт (`False`) — скажи, перезахвачу. 2. **Reply-обогащение (HTML-only) не замокано.** `_reply_enrichment` делает ЖИВОЙ `client.get_messages` — вне cache-hit пути, что мокает спека. С голым тест-клиентом оно стабильно no-op'ит (ошибка логируется/глотается) и падает обратно на записанный shallow `reply_to_message` из пикла. Детерминировано и верно «мокаем только tg_cache»; синтетические reply-таргеты НЕ фабриковал (это инъекция в оракул против guardrail'а «заморозить текущее поведение»). RSS не затронут. ## Checklist - [x] этап 0 первым, до правок кода; продакшн рендер не тронут - [x] корпус реальный (записанные сообщения), реплей = прод-путь cache-hit - [x] оракул зелёный + детерминизм подтверждён; нормализации минимальны и обоснованы - [ ] ack по двум judgment call'ам выше
agent_coder added 1 commit 2026-07-06 07:40:57 +03:00
Первый этап эпика рефакторинга рендер-пайплайна (#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>
agent_coder added the review/needs label 2026-07-06 07:41:07 +03:00
Collaborator

🤖 Авто-ревью · #35 (test(render): этап 0 — golden-эталоны фидов (оракул), #27), голова dc9a23f8

Вердикт: PASS (review/approved). Аккуратный equivalence-oracle: чисто аддитивный (прод-рендер не тронут), детерминированный, покрытие плотное. Мержибельно.

Проверено (реальный прогон, CI-fidelity)

  • Детерминизм — снят корректно (главная забота golden-фидов): два волатильных источника нормализуются СИММЕТРИЧНО (golden vs actual) — <lastBuildDate> (feedgen now()) стрипается, hash-упорядоченный list(set(...)) merged-flags div сортируется. Подписанные /media-URL детерминированы через фиксированный GOLDEN_SIGNING_KEY (monkeypatch KeyManager.signing_key) — «golden воспроизводится на любой машине». Плюс тесты на сами нормализаторы (test_normalization_sorts_merged_flags, test_normalization_strips_lastbuilddate) — load-bearing логика залочена.
  • Реплей = прод-путь: golden_replay.py распикливает recorded/*.cache|.chatinfo, monkeypatch cached_get_chat_history/cached_get_chat (буквально cache-hit прод-путь, образец test_stage4_eventloop.py); timestamp/limit игнорятся как в проде. conftest.py ставит config-mock ДО любого теста (снимает sys.modules['config'] order-pollution).
  • Гейт зелёный: pip install -r requirements.txt (CI-fidelity, kurigram 2.2.23); test_stage0_golden.py11/11 (4 RSS + 4 HTML golden + present-nonempty + 2 нормализатора); полный pytest tests/332 passed (321 + 11 новых), ноль регрессий от нового conftest/фикстур.
  • Security: реального секрета/токена в goldens нет — подпись фиксированным ТЕСТ-ключом, mock_config вместо реального конфига. Оракул замораживает ТЕКУЩЕЕ поведение (вкл. известные баги) намеренно — это и есть контракт этапа 0 (следующие этапы: изменение байтов без ссылки на §3 реестра = регрессия).
Заметка (не блокер)
  • pickle.load на recorded/*.cache — это СОБСТВЕННЫЙ закоммиченный корпус проекта (доверенные тест-фикстуры, зеркалит прод-формат кэша), не внешний ввод → приемлемо для тест-инфры. Ниже DROP-floor.
  • Судил аспекты напрямую (test-only, аддитив): coherence (цель #27 достигнута — оракул снят ДО правок), stability/regressions (прод-код не тронут, 332 passed), security (фикс-ключ, mock-config), conventions (следует test_stage4_eventloop.py).

Детерминизм golden-фидов (lastBuildDate/flags/подписанные media-URL) проверил как ключевой риск — снят корректно (11/11 + 332 full suite зелёные, fixed signing key). Прод-код не тронут. Ветка чистая.

## 🤖 Авто-ревью · #35 (test(render): этап 0 — golden-эталоны фидов (оракул), #27), голова `dc9a23f8` **Вердикт: PASS** ✅ (review/approved). Аккуратный equivalence-oracle: чисто аддитивный (прод-рендер не тронут), детерминированный, покрытие плотное. Мержибельно. ### Проверено (реальный прогон, CI-fidelity) - **Детерминизм — снят корректно** (главная забота golden-фидов): два волатильных источника нормализуются СИММЕТРИЧНО (golden vs actual) — `<lastBuildDate>` (feedgen `now()`) стрипается, hash-упорядоченный `list(set(...))` merged-flags div сортируется. Подписанные `/media`-URL детерминированы через **фиксированный `GOLDEN_SIGNING_KEY`** (monkeypatch `KeyManager.signing_key`) — «golden воспроизводится на любой машине». Плюс **тесты на сами нормализаторы** (`test_normalization_sorts_merged_flags`, `test_normalization_strips_lastbuilddate`) — load-bearing логика залочена. - **Реплей = прод-путь:** `golden_replay.py` распикливает `recorded/*.cache|.chatinfo`, monkeypatch `cached_get_chat_history`/`cached_get_chat` (буквально cache-hit прод-путь, образец `test_stage4_eventloop.py`); `timestamp`/`limit` игнорятся как в проде. `conftest.py` ставит config-mock ДО любого теста (снимает sys.modules['config'] order-pollution). - **Гейт зелёный:** `pip install -r requirements.txt` (CI-fidelity, kurigram 2.2.23); `test_stage0_golden.py` — **11/11** (4 RSS + 4 HTML golden + present-nonempty + 2 нормализатора); полный `pytest tests/` — **332 passed** (321 + 11 новых), ноль регрессий от нового conftest/фикстур. - **Security:** реального секрета/токена в goldens нет — подпись фиксированным ТЕСТ-ключом, `mock_config` вместо реального конфига. Оракул замораживает ТЕКУЩЕЕ поведение (вкл. известные баги) намеренно — это и есть контракт этапа 0 (следующие этапы: изменение байтов без ссылки на §3 реестра = регрессия). <details> <summary>Заметка (не блокер)</summary> - `pickle.load` на `recorded/*.cache` — это СОБСТВЕННЫЙ закоммиченный корпус проекта (доверенные тест-фикстуры, зеркалит прод-формат кэша), не внешний ввод → приемлемо для тест-инфры. Ниже DROP-floor. - Судил аспекты напрямую (test-only, аддитив): coherence (цель #27 достигнута — оракул снят ДО правок), stability/regressions (прод-код не тронут, 332 passed), security (фикс-ключ, mock-config), conventions (следует `test_stage4_eventloop.py`). </details> <sub>Детерминизм golden-фидов (lastBuildDate/flags/подписанные media-URL) проверил как ключевой риск — снят корректно (11/11 + 332 full suite зелёные, fixed signing key). Прод-код не тронут. Ветка чистая.</sub> <!-- state:review reviewed_head=dc9a23f801 round=1 verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-06 07:44:22 +03:00
Owner

ACK: time_based_merge=True — да, оставляй, не перезахватывай. Это не просто «ок», это единственно правильная база, и вот почему:

Прод так и работает — в dockercompose TIME_BASED_MERGE: True. Оракул обязан отражать продакшн, а не голый code-default (в config.py он False, но это неважно — прод его переопределяет). Сверил сам.
Это ЕДИНСТВЕННЫЙ способ покрыть time-clustering путь, который переписывает этап 4. С False весь _create_time_based_media_groups / _compute_time_based_group_ids остался бы вне golden — и тогда deepcopy→чистая-функция эквивалентность этапа 4 (#31) верифицировалась бы против пустоты. meow_design как реальный time-cluster канал — ровно то, что нужно.
Детерминизм гарантирован именно на этом корпусе: проверил — 400 сообщений, 0 None-date. Нестабильность True была бы только от datetime.now()-фолбэков на None-date постах, а их нет. Так что True здесь чист.
Твоя интуиция верна на всех трёх осях — фиксируем True как базу оракула для всего эпика.

Про reply-enrichment (второй пункт): решение принято правильно, но это оформленный пробел, не «мелочь». Не фабрикуй синтетику в golden — корпус должен оставаться реальным, тут ты прав. Но _reply_enrichment (HTML-only, живой get_messages мимо кэша) в харнессе no-op'ит → golden эту ветку не видит. А этап 3 (#30) как раз переносит enrichment в _prepare_feed_posts. Поэтому: этап 3 обязан нести отдельный точечный unit-тест на enrichment (мок client.get_messages с заполненным .reply_to_message; проверить батчинг по chat_id + проставление + рендер reply-блока). Записал это требование прямо в #30 — иначе перенос enrichment уедет непроверенным. На этапе 0 no-op принимается как есть.

Green-light: запускай этап 1 (sanitizer), стекнутым на этап 0. Он строится на этом оракуле — база зафиксирована правильно, каскад пойдёт от True. Напомню два момента из #28, чтобы не всплыли на ревью: полный вызов clean() с protocols=['http','https','tg'] И strip=True (оба — не дефолт bleach), и единое имя лога html_sanitization_error вместо трёх (реестр §3.16). Нормализацию сортировки merged-флагов, которую ты завёл на этапе 0, снимаешь на этапе 2 (#29, §3.8) — не забудь, она временная.

ACK: time_based_merge=True — да, оставляй, не перезахватывай. Это не просто «ок», это единственно правильная база, и вот почему: Прод так и работает — в dockercompose TIME_BASED_MERGE: True. Оракул обязан отражать продакшн, а не голый code-default (в config.py он False, но это неважно — прод его переопределяет). Сверил сам. Это ЕДИНСТВЕННЫЙ способ покрыть time-clustering путь, который переписывает этап 4. С False весь _create_time_based_media_groups / _compute_time_based_group_ids остался бы вне golden — и тогда deepcopy→чистая-функция эквивалентность этапа 4 (#31) верифицировалась бы против пустоты. meow_design как реальный time-cluster канал — ровно то, что нужно. Детерминизм гарантирован именно на этом корпусе: проверил — 400 сообщений, 0 None-date. Нестабильность True была бы только от datetime.now()-фолбэков на None-date постах, а их нет. Так что True здесь чист. Твоя интуиция верна на всех трёх осях — фиксируем True как базу оракула для всего эпика. Про reply-enrichment (второй пункт): решение принято правильно, но это оформленный пробел, не «мелочь». Не фабрикуй синтетику в golden — корпус должен оставаться реальным, тут ты прав. Но _reply_enrichment (HTML-only, живой get_messages мимо кэша) в харнессе no-op'ит → golden эту ветку не видит. А этап 3 (#30) как раз переносит enrichment в _prepare_feed_posts. Поэтому: этап 3 обязан нести отдельный точечный unit-тест на enrichment (мок client.get_messages с заполненным .reply_to_message; проверить батчинг по chat_id + проставление + рендер reply-блока). Записал это требование прямо в #30 — иначе перенос enrichment уедет непроверенным. На этапе 0 no-op принимается как есть. Green-light: запускай этап 1 (sanitizer), стекнутым на этап 0. Он строится на этом оракуле — база зафиксирована правильно, каскад пойдёт от True. Напомню два момента из #28, чтобы не всплыли на ревью: полный вызов clean() с protocols=['http','https','tg'] И strip=True (оба — не дефолт bleach), и единое имя лога html_sanitization_error вместо трёх (реестр §3.16). Нормализацию сортировки merged-флагов, которую ты завёл на этапе 0, снимаешь на этапе 2 (#29, §3.8) — не забудь, она временная.
vvzvlad added review/changes-requested and removed review/approved labels 2026-07-06 16:17:03 +03:00
Author
Collaborator

Принял ACK — правок в этап 0 не требуется, оставляю как есть.

  • time_based_merge=True зафиксирован как база оракула для всего эпика (прод его переопределяет на True, meow_design — реальный time-cluster, каскад по этапу 4/#31 верифицируется не против пустоты). Не перезахватываю.
  • reply-enrichment no-op на этапе 0 принят; отдельный unit-тест на enrichment (мок client.get_messages с заполненным .reply_to_message, батчинг по chat_id + проставление + рендер reply-блока) сделаю на этапе 3 (#30) при переносе enrichment в _prepare_feed_posts — вижу, ты записал требование в #30.
  • сортировка merged-флагов — временная нормализация этапа 0; снимаю её на этапе 2 (#29, §3.8), не забуду.

Ставлю обратно review/needs (менять в этапе 0 нечего — готов к мёржу). Запускаю этап 1 (sanitizer, #28) стекнутым на этап 0; учту оба напоминания: полный clean() с protocols=['http','https','tg'] И strip=True (оба не-дефолт bleach), и единое имя лога html_sanitization_error (§3.16).

Принял ACK — правок в этап 0 не требуется, оставляю как есть. - `time_based_merge=True` зафиксирован как база оракула для всего эпика (прод его переопределяет на True, meow_design — реальный time-cluster, каскад по этапу 4/#31 верифицируется не против пустоты). Не перезахватываю. - reply-enrichment no-op на этапе 0 принят; отдельный unit-тест на enrichment (мок `client.get_messages` с заполненным `.reply_to_message`, батчинг по chat_id + проставление + рендер reply-блока) сделаю на этапе 3 (#30) при переносе enrichment в `_prepare_feed_posts` — вижу, ты записал требование в #30. - сортировка merged-флагов — временная нормализация этапа 0; снимаю её на этапе 2 (#29, §3.8), не забуду. Ставлю обратно review/needs (менять в этапе 0 нечего — готов к мёржу). Запускаю этап 1 (sanitizer, #28) стекнутым на этап 0; учту оба напоминания: полный `clean()` с `protocols=['http','https','tg']` И `strip=True` (оба не-дефолт bleach), и единое имя лога `html_sanitization_error` (§3.16).
agent_coder added review/needs and removed review/changes-requested labels 2026-07-06 16:19:35 +03:00
Collaborator

🤖 Авто-ревью · #35 — ре-подтверждение (голова без изменений)

Вердикт: PASS (review/approved остаётся). Голова dc9a23f801байт-в-байт та же, что я одобрил ранее (комментарий выше). Лейбл слетел в review/needs после дизайн-обсуждения time_based_merge, а не из-за правок кода.

  • Обсуждение (vvzvlad ACK + agent_coder «правок в этап 0 не требуется, оставляю как есть») подтвердило: time_based_merge=True — правильная база оракула (прод в docker-compose использует TIME_BASED_MERGE=True, meow_design — реальный time-cluster). Это ровно то, что уже зафиксировано в golden_replay.py (monkeypatch) и что я видел при первом ревью. Кода не меняли.
  • Гейт на этой голове уже прогнан ранее и зелёный (golden 11/11, полный pytest tests/ 332 passed, детерминизм снят фиксированным signing-key + нормализацией). Ре-прогон на идентичном коммите не нужен.

Прежнее одобрение в силе. Мержибельно.

## 🤖 Авто-ревью · #35 — ре-подтверждение (голова без изменений) **Вердикт: PASS** ✅ (review/approved остаётся). Голова `dc9a23f801` — **байт-в-байт та же**, что я одобрил ранее (комментарий выше). Лейбл слетел в review/needs после дизайн-обсуждения `time_based_merge`, а не из-за правок кода. - Обсуждение (vvzvlad ACK + agent_coder «правок в этап 0 не требуется, оставляю как есть») подтвердило: `time_based_merge=True` — правильная база оракула (прод в docker-compose использует `TIME_BASED_MERGE=True`, `meow_design` — реальный time-cluster). Это ровно то, что уже зафиксировано в `golden_replay.py` (monkeypatch) и что я видел при первом ревью. **Кода не меняли.** - Гейт на этой голове уже прогнан ранее и зелёный (golden 11/11, полный `pytest tests/` 332 passed, детерминизм снят фиксированным signing-key + нормализацией). Ре-прогон на идентичном коммите не нужен. Прежнее одобрение в силе. Мержибельно. <!-- state:review reviewed_head=dc9a23f801 round=reconfirm verdict=pass -->
agent_reviewer added review/approved and removed review/needs labels 2026-07-06 16:20:50 +03:00
vvzvlad merged commit 3a24eafcc2 into main 2026-07-06 17:05:11 +03:00
Sign in to join this conversation.