Рефакторинг рендера [этап 0/6]: golden-эталоны фидов (оракул эквивалентности) #27

Closed
opened 2026-07-05 20:25:40 +03:00 by agent_vscode · 1 comment
Collaborator

Часть эпика «Рефакторинг пайплайна рендера». Спека: plans/2026-07-05-render-pipeline-refactor.md (v6), раздел «Этап 0». Выполняется ПЕРВЫМ, до любых правок кода. Line-refs действительны для f9550d8.

Цель

Снять полные снапшоты выходов generate_channel_rss (RSS XML) и generate_channel_html (HTML-фид) на корпусе сообщений и закрепить их тестом сравнения. Это оракул эквивалентности для всех этапов: изменение байтов фида без ссылки на пункт реестра §3 спеки = регрессия.

Корпус: записанные РЕАЛЬНЫЕ сообщения с прод-сервера (v6)

Источник — рабочий кэш бриджа data/tgcache/: пиклы ровно нужного формата — {channel}.cache = {'timestamp','limit','messages': List[Message]} (tg_cache._save_history_to_cache) и {channel}.chatinfo (для cached_get_chat).

Снапшот кэша УЖЕ снят и проверен (2026-07-05): data/tgcache_prod_2026-07-05/ — 90 .cache + 89 .chatinfo, 53 МБ (вне git). Совместимость пиклов подтверждена: все 179 файлов распикливаются под kurigram 2.2.23 без ошибок. Инвентарь (8582 сообщения): PHOTO 5397, text-only 1609, WEB_PAGE 764 (с фото 581 / без 183), VIDEO 653, DOCUMENT 51, ANIMATION 33, POLL 31, VIDEO_NOTE 15, STICKER 13, AUDIO 8, VOICE 6, GIVEAWAY/GIVEAWAY_WINNERS по 1; media_group-членов 3159, forward 670, reply 357, time-кластерных пар (gap≤5s) ~294; реакции: обычных 17981, custom 1018, paid 593, пустых reactions-объектов 17 (§3.15-edge покрыт реальными данными); зачёркиваний 150 (§3.1 покрыт); ВСЕ даты naive; None-date — 0.

Задача этапа: отобрать из снапшота пары файлов (несколько каналов, суммарно покрывающих инвентарь: media_groups, time-кластеры, poll, webpage±фото, forward, reply, реакции всех видов, зачёркивание) → закоммитить в tests/test_data/recorded/ → написать реплей-загрузчик и снять golden в tests/test_data/golden/.

Реплей: загрузчик распикливает файлы напрямую (timestamp/limit игнорируются — никакой проверки свежести) и monkeypatch'ит tg_cache.cached_get_chat_history / cached_get_chat на возврат записанных объектов. Это буквально прод-путь cache-hit. Lazy-import внутри фид-функций разрешает имена поздно — патч модуля tg_cache работает (образец — test_stage4_eventloop.py).

Синтетическая добавка — только недостижимое в записанном: None-date пост (добавляется этапом 4, не здесь). Отсутствующие в корпусе типы (PAID_MEDIA, STORY, LIVE_PHOTO, CHECKLIST и пр. экзотика) покрываются моками на fragment-уровне этапа 5, в feed-golden их нет.

НЕ входят в golden: сценарии exclude_flags/exclude_text (фильтры не меняют байты выживших постов — семантика закрепляется парой unit-тестов); None-date пост (текущий код падает: naive/aware TypeError в sort-ключе групп, rss_generator.py:141).

Обязательные меры детерминизма (все шесть)

  1. TZ раннера: os.environ['TZ'] = 'UTC'; time.tzset() в conftest (naive-даты интерпретируются .timestamp() в локальной TZ).
  2. Нормализация волатильных строк RSS XML при сравнении (regex, в обе стороны): <lastBuildDate> — feedgen 1.0.0 ставит now() один раз в конструкторе FeedGenerator (меняется между прогонами); <generator> — версии не содержит, нормализация — опциональная страховка.
  3. Порядок merged-флагов до этапа 2 недетерминирован (list(set(...)), rss_generator.py:260–265, PYTHONHASHSEED): содержимое <div class="message-flags"> нормализуется сортировкой. Снимается этапом 2 (§3.8).
  4. Ключ подписи media-URL: autouse monkeypatch.setattr(KeyManager, "signing_key", ...) (образец — tests/test_new_media_types.py) — иначе свежий checkout на CI генерирует новый ключ и все digest'ы меняются.
  5. Запись media-id: monkeypatch upsert_media_file_ids_bulk_sync (или DB_PATH → tmp_path; DB_PATH cwd-относителен, file_io.py:13). Образец — test_stage4_eventloop.py:164.
  6. Для канала с time-кластерами нужен time_based_merge=True: ключ читает только rss_generator.Config — его патча достаточно (post_parser.Config — независимый dict; патчить оба — дешёвая страховка).

Ожидаемые обновления golden по этапам (контекст)

Этап 1 (§3 пп.1, 3, 4; пп.2 и 5 — fail-closed, golden не видит — юнит-тесты sanitizer); этап 2 (пп.6, 8, 15 + снятие нормализации флагов); этап 4 (None-date фикстура в HTML-golden); этап 5b (п.14).

DoD

  • Отобранный корпус + реплей-загрузчик + снапшоты в репо; README тестов фиксирует: пиклы сцеплены с версией kurigram (2.2.23 запинена) — при апгрейде корпус перезаписывается с сервера;
  • два прогона pytest подряд — байт-идентичный результат;
  • любое изменение рендера валит тест сравнения с внятным диффом;
  • pytest полностью зелёный.

Общие правила эпика

Ветка refactor/render-pipeline от f9550d8; один этап = один коммит; комментарии в коде — English; точечные правки.

Часть эпика «Рефакторинг пайплайна рендера». Спека: `plans/2026-07-05-render-pipeline-refactor.md` (**v6**), раздел «Этап 0». Выполняется ПЕРВЫМ, до любых правок кода. Line-refs действительны для f9550d8. ## Цель Снять полные снапшоты выходов `generate_channel_rss` (RSS XML) и `generate_channel_html` (HTML-фид) на корпусе сообщений и закрепить их тестом сравнения. Это оракул эквивалентности для всех этапов: изменение байтов фида без ссылки на пункт реестра §3 спеки = регрессия. ## Корпус: записанные РЕАЛЬНЫЕ сообщения с прод-сервера (v6) Источник — рабочий кэш бриджа `data/tgcache/`: пиклы ровно нужного формата — `{channel}.cache` = `{'timestamp','limit','messages': List[Message]}` (`tg_cache._save_history_to_cache`) и `{channel}.chatinfo` (для `cached_get_chat`). **Снапшот кэша УЖЕ снят и проверен** (2026-07-05): `data/tgcache_prod_2026-07-05/` — 90 `.cache` + 89 `.chatinfo`, 53 МБ (вне git). Совместимость пиклов подтверждена: все 179 файлов распикливаются под kurigram 2.2.23 без ошибок. Инвентарь (8582 сообщения): PHOTO 5397, text-only 1609, WEB_PAGE 764 (с фото 581 / без 183), VIDEO 653, DOCUMENT 51, ANIMATION 33, POLL 31, VIDEO_NOTE 15, STICKER 13, AUDIO 8, VOICE 6, GIVEAWAY/GIVEAWAY_WINNERS по 1; media_group-членов 3159, forward 670, reply 357, time-кластерных пар (gap≤5s) ~294; реакции: обычных 17981, custom 1018, paid 593, пустых reactions-объектов 17 (§3.15-edge покрыт реальными данными); зачёркиваний 150 (§3.1 покрыт); ВСЕ даты naive; None-date — 0. **Задача этапа**: отобрать из снапшота пары файлов (несколько каналов, суммарно покрывающих инвентарь: media_groups, time-кластеры, poll, webpage±фото, forward, reply, реакции всех видов, зачёркивание) → закоммитить в `tests/test_data/recorded/` → написать реплей-загрузчик и снять golden в `tests/test_data/golden/`. **Реплей**: загрузчик распикливает файлы напрямую (`timestamp`/`limit` игнорируются — никакой проверки свежести) и monkeypatch'ит `tg_cache.cached_get_chat_history` / `cached_get_chat` на возврат записанных объектов. Это буквально прод-путь cache-hit. Lazy-import внутри фид-функций разрешает имена поздно — патч модуля tg_cache работает (образец — test_stage4_eventloop.py). **Синтетическая добавка** — только недостижимое в записанном: None-date пост (добавляется этапом 4, не здесь). Отсутствующие в корпусе типы (PAID_MEDIA, STORY, LIVE_PHOTO, CHECKLIST и пр. экзотика) покрываются моками на fragment-уровне этапа 5, в feed-golden их нет. НЕ входят в golden: сценарии exclude_flags/exclude_text (фильтры не меняют байты выживших постов — семантика закрепляется парой unit-тестов); None-date пост (текущий код падает: naive/aware TypeError в sort-ключе групп, rss_generator.py:141). ## Обязательные меры детерминизма (все шесть) 1. TZ раннера: `os.environ['TZ'] = 'UTC'; time.tzset()` в conftest (naive-даты интерпретируются `.timestamp()` в локальной TZ). 2. Нормализация волатильных строк RSS XML при сравнении (regex, в обе стороны): `<lastBuildDate>` — feedgen 1.0.0 ставит now() один раз в конструкторе `FeedGenerator` (меняется между прогонами); `<generator>` — версии не содержит, нормализация — опциональная страховка. 3. Порядок merged-флагов до этапа 2 недетерминирован (`list(set(...))`, rss_generator.py:260–265, PYTHONHASHSEED): содержимое `<div class="message-flags">` нормализуется сортировкой. Снимается этапом 2 (§3.8). 4. Ключ подписи media-URL: autouse `monkeypatch.setattr(KeyManager, "signing_key", ...)` (образец — tests/test_new_media_types.py) — иначе свежий checkout на CI генерирует новый ключ и все digest'ы меняются. 5. Запись media-id: monkeypatch `upsert_media_file_ids_bulk_sync` (или `DB_PATH` → tmp_path; `DB_PATH` cwd-относителен, file_io.py:13). Образец — test_stage4_eventloop.py:164. 6. Для канала с time-кластерами нужен `time_based_merge=True`: ключ читает только `rss_generator.Config` — его патча достаточно (`post_parser.Config` — независимый dict; патчить оба — дешёвая страховка). ## Ожидаемые обновления golden по этапам (контекст) Этап 1 (§3 пп.1, 3, 4; пп.2 и 5 — fail-closed, golden не видит — юнит-тесты sanitizer); этап 2 (пп.6, 8, 15 + снятие нормализации флагов); этап 4 (None-date фикстура в HTML-golden); этап 5b (п.14). ## DoD - Отобранный корпус + реплей-загрузчик + снапшоты в репо; README тестов фиксирует: пиклы сцеплены с версией kurigram (2.2.23 запинена) — при апгрейде корпус перезаписывается с сервера; - два прогона pytest подряд — байт-идентичный результат; - любое изменение рендера валит тест сравнения с внятным диффом; - `pytest` полностью зелёный. ## Общие правила эпика Ветка `refactor/render-pipeline` от f9550d8; один этап = один коммит; комментарии в коде — English; точечные правки.
Author
Collaborator

Отбор корпуса выполнен (жадно по матрице признаков из инвентаря), файлы уже лежат в tests/test_data/recorded/ (4 канала × .cache+.chatinfo, 2.7 МБ, пока untracked — закоммитить первым коммитом этапа):

  • bladerunnerblues (1.2 МБ) — единственный источник GIVEAWAY + GIVEAWAY_WINNERS; POLL, pdf, DOCUMENT, ANIMATION, r_paid×71, wp_nophoto;
  • theyforcedme (427 КБ) — STICKER + AUDIO + VOICE + VIDEO_NOTE + POLL + ANIMATION в одном канале; forward×13, reply×10;
  • embedoka (612 КБ) — r_empty + r_custom×35 + strike×7 + POLL + pdf + wp_nophoto; forward×20, reply×14;
  • meow_design (536 КБ) — 51 time-кластерная пара (ядро time_based_merge), media_groups×22, pdf, POLL, strike.

Суммарное покрытие: PHOTO 246, VIDEO 40, media_groups 66, forward 35, reply 27, wp_photo 11 — полная матрица закрыта, .chatinfo есть у всех четырёх.

Кодеру остаётся: реплей-загрузчик + меры детерминизма из ишью + снятие golden.

**Отбор корпуса выполнен** (жадно по матрице признаков из инвентаря), файлы уже лежат в `tests/test_data/recorded/` (4 канала × `.cache`+`.chatinfo`, 2.7 МБ, пока untracked — закоммитить первым коммитом этапа): - `bladerunnerblues` (1.2 МБ) — единственный источник GIVEAWAY + GIVEAWAY_WINNERS; POLL, pdf, DOCUMENT, ANIMATION, r_paid×71, wp_nophoto; - `theyforcedme` (427 КБ) — STICKER + AUDIO + VOICE + VIDEO_NOTE + POLL + ANIMATION в одном канале; forward×13, reply×10; - `embedoka` (612 КБ) — r_empty + r_custom×35 + strike×7 + POLL + pdf + wp_nophoto; forward×20, reply×14; - `meow_design` (536 КБ) — 51 time-кластерная пара (ядро time_based_merge), media_groups×22, pdf, POLL, strike. Суммарное покрытие: PHOTO 246, VIDEO 40, media_groups 66, forward 35, reply 27, wp_photo 11 — полная матрица закрыта, `.chatinfo` есть у всех четырёх. Кодеру остаётся: реплей-загрузчик + меры детерминизма из ишью + снятие golden.
Sign in to join this conversation.