refactor(render): этап 5a — таблица медиа-типов (байт-в-байт) (#32)
Три параллельные ручно-синхронизируемые «лестницы» выбора media-объекта (_get_file_unique_id dict-map, _save_media_file_ids if/elif, _generate_html_media elif-каскад) сведены в одну точку правды: MEDIA_SOURCES (селектор → (media_obj, render_kind), 12 записей) + RenderCtx + RENDERERS (7 рендереров подняты дословно из старых веток). Все три консьюмера теперь ходят через таблицу; uid единообразно через getattr(file_unique_id). Байт-в-байт: fragment-oracle (media_fragment_replay + media_fragments.json, 24 кейса) без изменений, feed-golden без изменений. Единственная унификация — _save_media_file_ids теперь ключуется на message.media (как две другие лестницы), что заодно чинит латентную рассинхронизацию (в корпусе не встречается → оракул 0). test_stage5_media_table.py (48 тестов). Полный сюит 413 passed, PYTHONHASHSEED 1/42. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+181
-116
@@ -14,8 +14,9 @@ import re
|
||||
import os
|
||||
import html
|
||||
import inspect
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Union, Dict, Any, List, Optional
|
||||
from typing import Union, Dict, Any, List, Optional, Callable, Tuple
|
||||
from pyrogram.types import Message
|
||||
from pyrogram.enums import MessageMediaType
|
||||
from sanitizer import sanitize_html
|
||||
@@ -107,6 +108,138 @@ def _story_media_object(message):
|
||||
return None, None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Single source of truth for media handling (render-pipeline refactor, stage 5).
|
||||
#
|
||||
# MEDIA_SOURCES maps a media type to a selector returning (media object, render
|
||||
# kind). It replaces the three hand-synced ladders that used to live in
|
||||
# _get_file_unique_id (dict map), _save_media_file_ids (if/elif) and
|
||||
# _generate_html_media (elif cascade). All three now go through this table.
|
||||
#
|
||||
# kind=None means "selector-only entry": the object participates in file-id
|
||||
# extraction/collection but is NOT rendered by the dispatcher. The ONLY kind=None
|
||||
# entry is WEB_PAGE (its preview is rendered by _format_webpage). PAID_MEDIA has
|
||||
# NO entry at all: its info block is a dedicated branch in _generate_html_media
|
||||
# and it is deliberately never collected/downloaded (paid content).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _select_document(message):
|
||||
"""DOCUMENT selector: PDF documents render as a t.me link ('pdf'), everything
|
||||
else as an image ('img_400'). The selected object is always message.document."""
|
||||
document = message.document
|
||||
if (document is not None and hasattr(document, 'mime_type')
|
||||
and document.mime_type == 'application/pdf'):
|
||||
return document, 'pdf'
|
||||
return document, 'img_400'
|
||||
|
||||
|
||||
def _select_sticker(message):
|
||||
"""STICKER selector: video stickers loop as <video> ('video_loop_200'), image
|
||||
stickers render as <img> ('img_200_sticker')."""
|
||||
sticker = message.sticker
|
||||
if getattr(sticker, 'is_video', False):
|
||||
return sticker, 'video_loop_200'
|
||||
return sticker, 'img_200_sticker'
|
||||
|
||||
|
||||
# Helper kinds returned by _story_media_object / _poll_media_object map to render kinds.
|
||||
_HELPER_KIND_TO_RENDER = {'video': 'video_400', 'img': 'img_400'}
|
||||
|
||||
|
||||
def _select_story(message):
|
||||
"""STORY selector: object choice (video over photo) is delegated to
|
||||
_story_media_object; its helper kind is mapped to a render kind."""
|
||||
media_obj, helper_kind = _story_media_object(message)
|
||||
return media_obj, _HELPER_KIND_TO_RENDER.get(helper_kind)
|
||||
|
||||
|
||||
def _select_poll_media(message):
|
||||
"""POLL selector: object comes from _poll_media_object (description_media); its
|
||||
helper kind is mapped to a render kind."""
|
||||
media_obj, helper_kind = _poll_media_object(message)
|
||||
return media_obj, _HELPER_KIND_TO_RENDER.get(helper_kind)
|
||||
|
||||
|
||||
MEDIA_SOURCES: Dict[Any, Callable[[Message], Tuple[Any, Optional[str]]]] = {
|
||||
MessageMediaType.PHOTO: lambda m: (m.photo, 'img_400'),
|
||||
MessageMediaType.VIDEO: lambda m: (m.video, 'video_400'),
|
||||
MessageMediaType.ANIMATION: lambda m: (m.animation, 'video_400'),
|
||||
MessageMediaType.VIDEO_NOTE: lambda m: (m.video_note, 'video_400'),
|
||||
MessageMediaType.AUDIO: lambda m: (m.audio, 'audio'),
|
||||
MessageMediaType.VOICE: lambda m: (m.voice, 'audio'),
|
||||
MessageMediaType.DOCUMENT: _select_document,
|
||||
MessageMediaType.STICKER: _select_sticker,
|
||||
MessageMediaType.LIVE_PHOTO: lambda m: (getattr(m, 'live_photo', None), 'video_loop_400'),
|
||||
MessageMediaType.STORY: _select_story,
|
||||
MessageMediaType.POLL: _select_poll_media,
|
||||
MessageMediaType.WEB_PAGE: lambda m: (getattr(m.web_page, 'photo', None), None),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderCtx:
|
||||
"""Everything a renderer needs. _generate_html_media assembles it (URL signing,
|
||||
channel_username guard, and the mime/emoji/tg_link values chosen BY MEDIA TYPE);
|
||||
a renderer only formats the string and never inspects the Message."""
|
||||
url: str
|
||||
tg_link: Optional[str] = None # t.me deep link — only the 'pdf' renderer uses it
|
||||
emoji: str = '' # sticker alt text
|
||||
mime: Optional[str] = None # audio/voice <source> type; default chosen by media type
|
||||
|
||||
|
||||
# Renderers return list[str] so the byte structure of the '\n'.join in
|
||||
# _generate_html_media is preserved (audio emits TWO items: <audio> tag + <br>;
|
||||
# pdf emits its two-append div block). Bodies are lifted VERBATIM from the old
|
||||
# if/elif branches, including the `src="{url}"style=` concatenation artifacts —
|
||||
# byte-for-byte fidelity is the stage-5a contract.
|
||||
def _render_img_400(ctx: 'RenderCtx') -> List[str]:
|
||||
return [f'<img src="{ctx.url}" style="max-width:100%; width:auto; height:auto;'
|
||||
f'max-height:400px; object-fit:contain;">']
|
||||
|
||||
|
||||
def _render_video_400(ctx: 'RenderCtx') -> List[str]:
|
||||
return [f'<video controls src="{ctx.url}" style="max-width:100%; width:auto;'
|
||||
f'height:auto; max-height:400px;"></video>']
|
||||
|
||||
|
||||
def _render_audio(ctx: 'RenderCtx') -> List[str]:
|
||||
return [f'<audio controls style="width:100%; max-width:400px;">'
|
||||
f'<source src="{ctx.url}" type="{ctx.mime}"></audio>',
|
||||
'<br>']
|
||||
|
||||
|
||||
def _render_video_loop_200(ctx: 'RenderCtx') -> List[str]:
|
||||
return [f'<video controls autoplay loop muted src="{ctx.url}"'
|
||||
f'style="max-width:100%; width:auto; height:auto; max-height:200px;'
|
||||
f'object-fit:contain;"></video>']
|
||||
|
||||
|
||||
def _render_img_200_sticker(ctx: 'RenderCtx') -> List[str]:
|
||||
return [f'<img src="{ctx.url}" alt="Sticker {ctx.emoji}" style="max-width:100%;'
|
||||
f'width:auto; height:auto; max-height:200px; object-fit:contain;">']
|
||||
|
||||
|
||||
def _render_video_loop_400(ctx: 'RenderCtx') -> List[str]:
|
||||
return [f'<video controls autoplay loop muted src="{ctx.url}"'
|
||||
f'style="max-width:100%; width:auto; height:auto; max-height:400px;'
|
||||
f'object-fit:contain;"></video>']
|
||||
|
||||
|
||||
def _render_pdf(ctx: 'RenderCtx') -> List[str]:
|
||||
return [f'<div class="document-pdf" style="padding: 10px;">',
|
||||
f'<a href="{ctx.tg_link}" target="_blank">[PDF-файл]</a></div>']
|
||||
|
||||
|
||||
RENDERERS: Dict[str, Callable[['RenderCtx'], List[str]]] = {
|
||||
'img_400': _render_img_400,
|
||||
'video_400': _render_video_400,
|
||||
'audio': _render_audio,
|
||||
'video_loop_200': _render_video_loop_200,
|
||||
'img_200_sticker': _render_img_200_sticker,
|
||||
'video_loop_400': _render_video_loop_400,
|
||||
'pdf': _render_pdf,
|
||||
}
|
||||
|
||||
|
||||
#tests
|
||||
#http://127.0.0.1:8000/post/html/DragorWW_space/114 — video
|
||||
#http://127.0.0.1:8000/post/html/DragorWW_space/20 - many photos
|
||||
@@ -848,72 +981,31 @@ class PostParser:
|
||||
|
||||
logger.debug(f"Collected media file: {channel_username}/{message.id}/{file_unique_id}")
|
||||
|
||||
# Check if document is a PDF file
|
||||
if (message.media == MessageMediaType.DOCUMENT and
|
||||
message.document is not None and hasattr(message.document, 'mime_type') and
|
||||
message.document.mime_type == 'application/pdf'):
|
||||
# Only attempt to create link if channel_username is available
|
||||
if channel_username.startswith('-100'):
|
||||
tg_link = f"https://t.me/c/{channel_username[4:]}/{message.id}"
|
||||
else:
|
||||
tg_link = f"https://t.me/{channel_username}/{message.id}"
|
||||
content_media.append(f'<div class="document-pdf" style="padding: 10px;">')
|
||||
content_media.append(f'<a href="{tg_link}" target="_blank">[PDF-файл]</a></div>')
|
||||
elif message.media in [MessageMediaType.PHOTO, MessageMediaType.DOCUMENT]:
|
||||
content_media.append(f'<img src="{url}" style="max-width:100%; width:auto; height:auto;'
|
||||
f'max-height:400px; object-fit:contain;">')
|
||||
elif message.media == MessageMediaType.VIDEO:
|
||||
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
|
||||
f'height:auto; max-height:400px;"></video>')
|
||||
elif message.media == MessageMediaType.ANIMATION:
|
||||
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
|
||||
f'height:auto; max-height:400px;"></video>')
|
||||
elif message.media == MessageMediaType.VIDEO_NOTE:
|
||||
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
|
||||
f'height:auto; max-height:400px;"></video>')
|
||||
elif message.media == MessageMediaType.AUDIO:
|
||||
mime_type = getattr(message.audio, 'mime_type', 'audio/mpeg')
|
||||
content_media.append(f'<audio controls style="width:100%; max-width:400px;">'
|
||||
f'<source src="{url}" type="{mime_type}"></audio>')
|
||||
content_media.append('<br>')
|
||||
elif message.media == MessageMediaType.VOICE:
|
||||
mime_type = getattr(message.voice, 'mime_type', 'audio/ogg')
|
||||
content_media.append(f'<audio controls style="width:100%; max-width:400px;">'
|
||||
f'<source src="{url}" type="{mime_type}"></audio>')
|
||||
content_media.append('<br>')
|
||||
elif message.media == MessageMediaType.STICKER:
|
||||
emoji = getattr(message.sticker, 'emoji', '')
|
||||
if getattr(message.sticker, 'is_video', False):
|
||||
content_media.append(f'<video controls autoplay loop muted src="{url}"'
|
||||
f'style="max-width:100%; width:auto; height:auto; max-height:200px;'
|
||||
f'object-fit:contain;"></video>')
|
||||
else:
|
||||
content_media.append(f'<img src="{url}" alt="Sticker {emoji}" style="max-width:100%;'
|
||||
f'width:auto; height:auto; max-height:200px; object-fit:contain;">')
|
||||
elif message.media == MessageMediaType.LIVE_PHOTO:
|
||||
# Live photo is effectively a short video clip.
|
||||
content_media.append(f'<video controls autoplay loop muted src="{url}"'
|
||||
f'style="max-width:100%; width:auto; height:auto; max-height:400px;'
|
||||
f'object-fit:contain;"></video>')
|
||||
elif message.media == MessageMediaType.STORY:
|
||||
# Choose the tag from the SAME helper that produced the URL's
|
||||
# file_unique_id, so the tag type always matches the URL object.
|
||||
story_media_obj, story_media_kind = _story_media_object(message)
|
||||
if story_media_obj is not None and story_media_kind == 'video':
|
||||
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
|
||||
f'height:auto; max-height:400px;"></video>')
|
||||
elif story_media_obj is not None:
|
||||
content_media.append(f'<img src="{url}" style="max-width:100%; width:auto; height:auto;'
|
||||
f'max-height:400px; object-fit:contain;">')
|
||||
elif message.media == MessageMediaType.POLL and poll_media_obj is not None:
|
||||
if poll_media_kind == 'video':
|
||||
content_media.append(f'<video controls src="{url}" style="max-width:100%; width:auto;'
|
||||
f'height:auto; max-height:400px;"></video>')
|
||||
else:
|
||||
content_media.append(f'<img src="{url}" style="max-width:100%; width:auto; height:auto;'
|
||||
f'max-height:400px; object-fit:contain;">')
|
||||
# Dispatch through the media table: the selector gives the render
|
||||
# kind, the RENDERERS map gives the fragment. mime/emoji/tg_link
|
||||
# defaults are chosen here BY MEDIA TYPE (the renderer never guesses);
|
||||
# kind None (WEB_PAGE) emits no element — the empty container matches
|
||||
# the old cascade where no branch matched.
|
||||
selector = MEDIA_SOURCES.get(message.media)
|
||||
kind = selector(message)[1] if selector is not None else None
|
||||
renderer = RENDERERS.get(kind) if kind else None
|
||||
if renderer is not None:
|
||||
ctx = RenderCtx(url=url)
|
||||
if kind == 'pdf':
|
||||
if channel_username.startswith('-100'):
|
||||
ctx.tg_link = f"https://t.me/c/{channel_username[4:]}/{message.id}"
|
||||
else:
|
||||
ctx.tg_link = f"https://t.me/{channel_username}/{message.id}"
|
||||
elif kind == 'audio':
|
||||
if message.media == MessageMediaType.VOICE:
|
||||
ctx.mime = getattr(message.voice, 'mime_type', 'audio/ogg')
|
||||
else:
|
||||
ctx.mime = getattr(message.audio, 'mime_type', 'audio/mpeg')
|
||||
elif kind == 'img_200_sticker':
|
||||
ctx.emoji = getattr(message.sticker, 'emoji', '')
|
||||
content_media.extend(renderer(ctx))
|
||||
content_media.append('</div>')
|
||||
|
||||
|
||||
if webpage := getattr(message, "web_page", None): # Web page preview
|
||||
if webpage_html := self._format_webpage(webpage, message):
|
||||
if len((message.text or message.caption or '').strip()) <= 10:
|
||||
@@ -1231,28 +1323,12 @@ class PostParser:
|
||||
|
||||
def _get_file_unique_id(self, message: Message) -> Union[str, None]:
|
||||
try:
|
||||
media_mapping = {
|
||||
MessageMediaType.PHOTO: lambda m: m.photo.file_unique_id,
|
||||
MessageMediaType.VIDEO: lambda m: m.video.file_unique_id,
|
||||
MessageMediaType.DOCUMENT: lambda m: m.document.file_unique_id,
|
||||
MessageMediaType.AUDIO: lambda m: m.audio.file_unique_id,
|
||||
MessageMediaType.VOICE: lambda m: m.voice.file_unique_id,
|
||||
MessageMediaType.VIDEO_NOTE: lambda m: m.video_note.file_unique_id,
|
||||
MessageMediaType.ANIMATION: lambda m: m.animation.file_unique_id,
|
||||
MessageMediaType.STICKER: lambda m: m.sticker.file_unique_id,
|
||||
MessageMediaType.WEB_PAGE: lambda m: m.web_page.photo.file_unique_id if m.web_page and m.web_page.photo else None,
|
||||
# New media types (Kurigram 2.2.23): getattr-only access, the
|
||||
# attributes do not exist on older Message objects/mocks.
|
||||
MessageMediaType.LIVE_PHOTO: lambda m: getattr(getattr(m, 'live_photo', None), 'file_unique_id', None),
|
||||
MessageMediaType.STORY: lambda m: getattr(_story_media_object(m)[0], 'file_unique_id', None),
|
||||
MessageMediaType.POLL: lambda m: getattr(_poll_media_object(m)[0], 'file_unique_id', None),
|
||||
}
|
||||
|
||||
if message.media in media_mapping:
|
||||
return media_mapping[message.media](message)
|
||||
|
||||
return None
|
||||
|
||||
selector = MEDIA_SOURCES.get(message.media)
|
||||
if selector is None:
|
||||
return None
|
||||
selected_obj, _kind = selector(message)
|
||||
return getattr(selected_obj, 'file_unique_id', None)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"file_id_extraction_error: media_type {message.media}, error {str(e)}")
|
||||
return None
|
||||
@@ -1289,40 +1365,29 @@ class PostParser:
|
||||
return
|
||||
|
||||
if message.media:
|
||||
# Skip large videos - they shouldn't be cached permanently
|
||||
# Skip large videos - they shouldn't be cached permanently.
|
||||
# 5a keeps this message.video-only guard verbatim; registry §3.13
|
||||
# generalises the >100MB rule to every selected object in 5b.
|
||||
if message.video and message.video.file_size and message.video.file_size > 100 * 1024 * 1024:
|
||||
return
|
||||
|
||||
file_unique_id = ''
|
||||
new_media_obj = None # selected object among the Kurigram 2.2.23 media sources
|
||||
if message.photo: file_unique_id = message.photo.file_unique_id
|
||||
elif message.video: file_unique_id = message.video.file_unique_id
|
||||
elif message.document: file_unique_id = message.document.file_unique_id
|
||||
elif message.audio: file_unique_id = message.audio.file_unique_id
|
||||
elif message.voice: file_unique_id = message.voice.file_unique_id
|
||||
elif message.video_note: file_unique_id = message.video_note.file_unique_id
|
||||
elif message.animation: file_unique_id = message.animation.file_unique_id
|
||||
elif message.sticker: file_unique_id = message.sticker.file_unique_id
|
||||
elif message.web_page and message.web_page.photo:
|
||||
file_unique_id = message.web_page.photo.file_unique_id
|
||||
# New media types (Kurigram 2.2.23): getattr-only, the attributes do
|
||||
# not exist on older Message objects/mocks. paid_media is deliberately
|
||||
# NOT collected — it cannot be downloaded (paid content).
|
||||
elif getattr(message, 'live_photo', None):
|
||||
new_media_obj = message.live_photo
|
||||
elif (story_media := _story_media_object(message)[0]) is not None:
|
||||
new_media_obj = story_media
|
||||
elif (poll_media := _poll_media_object(message)[0]) is not None:
|
||||
new_media_obj = poll_media
|
||||
# Object selection now goes through the single MEDIA_SOURCES table
|
||||
# instead of the old truthy-attribute if/elif ladder. paid_media has no
|
||||
# table entry and is deliberately NOT collected (paid content).
|
||||
selector = MEDIA_SOURCES.get(message.media)
|
||||
selected_obj = selector(message)[0] if selector is not None else None
|
||||
|
||||
if new_media_obj is not None:
|
||||
# The >100MB message.video guard above does not cover these
|
||||
# sources (live photo, story video, poll description video) —
|
||||
# apply the same "don't cache large videos" rule here.
|
||||
file_size = getattr(new_media_obj, 'file_size', None)
|
||||
# The message.video guard above does not cover the Kurigram 2.2.23
|
||||
# sources (live photo, story video, poll description video) — apply the
|
||||
# same "don't cache large videos" rule to them (5b unifies this — §3.13).
|
||||
if message.media in (MessageMediaType.LIVE_PHOTO,
|
||||
MessageMediaType.STORY,
|
||||
MessageMediaType.POLL):
|
||||
file_size = getattr(selected_obj, 'file_size', None)
|
||||
if isinstance(file_size, int) and file_size > 100 * 1024 * 1024:
|
||||
return
|
||||
file_unique_id = getattr(new_media_obj, 'file_unique_id', '') or ''
|
||||
|
||||
file_unique_id = getattr(selected_obj, 'file_unique_id', '') or ''
|
||||
|
||||
if file_unique_id:
|
||||
added_ts = datetime.now().timestamp()
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=import-outside-toplevel, missing-function-docstring, line-too-long
|
||||
"""Stage-5 fragment-level snapshot helpers (render-pipeline refactor epic, issue #32/#34).
|
||||
|
||||
Captures the raw `_generate_html_media` output (BEFORE sanitize) for every media
|
||||
render kind and edge branch, as a SEPARATE oracle layer from the stage-0 feed goldens
|
||||
(spec §4: "два слоя эталонов, не смешивать"). The 5a refactor (MEDIA_SOURCES table +
|
||||
renderers) must reproduce these fragments byte-for-byte; 5b intentionally changes two
|
||||
of them (§3.13 large-file guard is collection-only, §3.14 unclosed-div close).
|
||||
|
||||
Cases whose type exists in the recorded corpus could be pulled from it, but the media
|
||||
FRAGMENT is a pure function of a single Message, so deterministic hand-built mocks give
|
||||
the same bytes with far less machinery and also reach the mock-only exotic types
|
||||
(PAID_MEDIA, LIVE_PHOTO, STORY) — exactly the set the spec allows mocks for.
|
||||
|
||||
Run `python -m tests.media_fragment_replay` from the repo root to (re)generate the snapshot.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(TESTS_DIR)
|
||||
SNAPSHOT_PATH = os.path.join(TESTS_DIR, "test_data", "media_fragments.json")
|
||||
|
||||
# Fixed signing key so digests in the snapshot reproduce on any checkout (mirrors the
|
||||
# stage-0 golden pin). Applied by the generator and by the comparison test.
|
||||
FRAGMENT_SIGNING_KEY = "stage5-fragment-fixed-signing-key-000000000000"
|
||||
|
||||
|
||||
class _Str(str):
|
||||
"""Minimal stand-in for Pyrogram's Str: .html returns the raw string unchanged."""
|
||||
@property
|
||||
def html(self):
|
||||
return str(self)
|
||||
|
||||
|
||||
def _msg(mid, media, *, text=None, username="testchan", chat_id=-1001234567890, **extra):
|
||||
"""Build a Message-like mock with the attributes _generate_html_media touches.
|
||||
|
||||
Top-level media attributes default to None (truthy checks in _save_media_file_ids);
|
||||
new Kurigram 2.2.23 attributes (live_photo/story/giveaway/...) are intentionally
|
||||
absent unless passed, so getattr-only production code is exercised as on real objects.
|
||||
"""
|
||||
m = SimpleNamespace()
|
||||
m.id = mid
|
||||
m.media = media
|
||||
m.text = _Str(text) if text is not None else None
|
||||
m.caption = None
|
||||
m.web_page = None
|
||||
m.poll = None
|
||||
m.paid_media = None
|
||||
m.forward_origin = None
|
||||
m.show_caption_above_media = False
|
||||
if chat_id is None and username is None:
|
||||
m.chat = None
|
||||
else:
|
||||
m.chat = SimpleNamespace(id=chat_id, username=username)
|
||||
for attr in ("photo", "video", "document", "audio", "voice",
|
||||
"video_note", "animation", "sticker"):
|
||||
setattr(m, attr, None)
|
||||
for key, value in extra.items():
|
||||
setattr(m, key, value)
|
||||
return m
|
||||
|
||||
|
||||
def _obj(**kw):
|
||||
return SimpleNamespace(**kw)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fragment cases. Each entry: name -> Message factory.
|
||||
# Covers the full spec §5a "Шаг 0" list + edge branches.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _webpage(photo=None, url="https://example.com", title="Example",
|
||||
description=None, site_name=None, wp_type=""):
|
||||
return _obj(photo=photo, url=url, title=title, description=description,
|
||||
site_name=site_name, type=wp_type, display_url=None)
|
||||
|
||||
|
||||
def build_cases():
|
||||
from pyrogram.enums import MessageMediaType as T
|
||||
cases = {}
|
||||
cases["photo"] = lambda: _msg(1, T.PHOTO, photo=_obj(file_unique_id="ph_uid", file_id="ph_fid"))
|
||||
cases["video"] = lambda: _msg(2, T.VIDEO, video=_obj(file_unique_id="vid_uid", file_id="vid_fid", file_size=1024))
|
||||
cases["animation"] = lambda: _msg(3, T.ANIMATION, animation=_obj(file_unique_id="ani_uid", file_id="ani_fid"))
|
||||
cases["video_note"] = lambda: _msg(4, T.VIDEO_NOTE, video_note=_obj(file_unique_id="vn_uid", file_id="vn_fid"))
|
||||
cases["audio_default_mime"] = lambda: _msg(5, T.AUDIO, audio=_obj(file_unique_id="au_uid", file_id="au_fid"))
|
||||
cases["audio_explicit_mime"] = lambda: _msg(6, T.AUDIO, audio=_obj(file_unique_id="au2_uid", file_id="au2_fid", mime_type="audio/flac"))
|
||||
cases["voice_default_mime"] = lambda: _msg(7, T.VOICE, voice=_obj(file_unique_id="vo_uid", file_id="vo_fid"))
|
||||
cases["voice_explicit_mime"] = lambda: _msg(8, T.VOICE, voice=_obj(file_unique_id="vo2_uid", file_id="vo2_fid", mime_type="audio/wav"))
|
||||
cases["sticker_img"] = lambda: _msg(9, T.STICKER, sticker=_obj(file_unique_id="st_uid", file_id="st_fid", emoji="😀", is_video=False))
|
||||
cases["sticker_video"] = lambda: _msg(10, T.STICKER, sticker=_obj(file_unique_id="stv_uid", file_id="stv_fid", emoji="🎬", is_video=True))
|
||||
cases["document_pdf_public"] = lambda: _msg(11, T.DOCUMENT, username="pubchan", chat_id=None,
|
||||
document=_obj(file_unique_id="pdf_uid", file_id="pdf_fid", mime_type="application/pdf"))
|
||||
cases["document_pdf_private"] = lambda: _msg(12, T.DOCUMENT, username=None, chat_id=-1009876543210,
|
||||
document=_obj(file_unique_id="pdf2_uid", file_id="pdf2_fid", mime_type="application/pdf"))
|
||||
cases["document_normal"] = lambda: _msg(13, T.DOCUMENT, document=_obj(file_unique_id="doc_uid", file_id="doc_fid", mime_type="image/png"))
|
||||
cases["live_photo"] = lambda: _msg(14, T.LIVE_PHOTO, live_photo=_obj(file_unique_id="lp_uid", file_id="lp_fid"))
|
||||
cases["story_video"] = lambda: _msg(15, T.STORY, story=_obj(video=_obj(file_unique_id="sv_uid", file_id="sv_fid"), photo=None))
|
||||
cases["story_photo"] = lambda: _msg(16, T.STORY, story=_obj(video=None, photo=_obj(file_unique_id="sp_uid", file_id="sp_fid")))
|
||||
cases["poll_media_img"] = lambda: _msg(17, T.POLL, poll=_obj(question=_Str("Q?"), options=[],
|
||||
description_media=_obj(photo=_obj(file_unique_id="pl_uid", file_id="pl_fid"))))
|
||||
cases["poll_media_video"] = lambda: _msg(18, T.POLL, poll=_obj(question=_Str("Q?"), options=[],
|
||||
description_media=_obj(video=_obj(file_unique_id="plv_uid", file_id="plv_fid"))))
|
||||
cases["paid_media"] = lambda: _msg(19, T.PAID_MEDIA,
|
||||
paid_media=_obj(stars_amount=50, media=[_obj(), _obj()]))
|
||||
# WEB_PAGE with photo: opens an EMPTY message-media div (no elif matches WEB_PAGE),
|
||||
# plus the webpage-preview block (short text gate <=10).
|
||||
cases["webpage_with_photo"] = lambda: _msg(20, T.WEB_PAGE, text="hi",
|
||||
web_page=_webpage(photo=_obj(file_unique_id="wp_uid", file_id="wp_fid")))
|
||||
# WEB_PAGE without photo: file_unique_id is None -> message-media div opened and
|
||||
# left UNCLOSED (§3.14 target). Short text so the preview block renders.
|
||||
cases["webpage_without_photo"] = lambda: _msg(21, T.WEB_PAGE, text="hi",
|
||||
web_page=_webpage(photo=None))
|
||||
# WEB_PAGE with photo but long text (>10): preview gate closed, only the empty media div.
|
||||
cases["webpage_photo_long_text"] = lambda: _msg(22, T.WEB_PAGE,
|
||||
text="this text is definitely longer than ten characters",
|
||||
web_page=_webpage(photo=_obj(file_unique_id="wpl_uid", file_id="wpl_fid")))
|
||||
# file_unique_id is None on a normal media type -> unclosed div (§3.14 target).
|
||||
cases["file_unique_id_none"] = lambda: _msg(23, T.PHOTO, photo=_obj(file_unique_id=None, file_id="x_fid"))
|
||||
# channel_username is None but uid present -> the div IS closed on the guard branch.
|
||||
cases["channel_username_none"] = lambda: _msg(24, T.PHOTO, username=None, chat_id=555,
|
||||
photo=_obj(file_unique_id="cu_uid", file_id="cu_fid"))
|
||||
return cases
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Capture / compare.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def capture_fragments():
|
||||
"""Return {case_name: {"html": fragment, "collected": [[chan, id, fuid], ...]}}.
|
||||
|
||||
Covers all three ladders in one shot: _generate_html_media renders the fragment
|
||||
(render ladder + _get_file_unique_id ladder) and calls _save_media_file_ids
|
||||
(collection ladder), whose result is read off _pending_media_ids."""
|
||||
from post_parser import PostParser
|
||||
parser = PostParser(SimpleNamespace())
|
||||
out = {}
|
||||
for name, factory in build_cases().items():
|
||||
parser._pending_media_ids = []
|
||||
html = parser._generate_html_media(factory())
|
||||
collected = [[c, p, f] for c, p, f, _ in parser._pending_media_ids]
|
||||
out[name] = {"html": html, "collected": collected}
|
||||
return out
|
||||
|
||||
|
||||
def pin_signing_key(monkeypatch):
|
||||
from url_signer import KeyManager
|
||||
monkeypatch.setattr(KeyManager, "signing_key", FRAGMENT_SIGNING_KEY)
|
||||
|
||||
|
||||
def load_snapshot():
|
||||
with open(SNAPSHOT_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _bootstrap_standalone():
|
||||
import sys
|
||||
import time
|
||||
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_snapshot():
|
||||
from url_signer import KeyManager
|
||||
KeyManager.signing_key = FRAGMENT_SIGNING_KEY
|
||||
data = capture_fragments()
|
||||
os.makedirs(os.path.dirname(SNAPSHOT_PATH), exist_ok=True)
|
||||
with open(SNAPSHOT_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
print(f"wrote {len(data)} fragment snapshots to {SNAPSHOT_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_bootstrap_standalone()
|
||||
generate_snapshot()
|
||||
@@ -0,0 +1,218 @@
|
||||
{
|
||||
"animation": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
3,
|
||||
"ani_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/3/ani_uid/ef4319c9\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
|
||||
},
|
||||
"audio_default_mime": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
5,
|
||||
"au_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/5/au_uid/be14c993\" type=\"audio/mpeg\"></audio>\n<br>\n</div>"
|
||||
},
|
||||
"audio_explicit_mime": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
6,
|
||||
"au2_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/6/au2_uid/015e2737\" type=\"audio/flac\"></audio>\n<br>\n</div>"
|
||||
},
|
||||
"channel_username_none": {
|
||||
"collected": [],
|
||||
"html": "<div class=\"message-media\">\n</div>"
|
||||
},
|
||||
"document_normal": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
13,
|
||||
"doc_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/13/doc_uid/4c9075c7\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
|
||||
},
|
||||
"document_pdf_private": {
|
||||
"collected": [
|
||||
[
|
||||
"-1009876543210",
|
||||
12,
|
||||
"pdf2_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<div class=\"document-pdf\" style=\"padding: 10px;\">\n<a href=\"https://t.me/c/9876543210/12\" target=\"_blank\">[PDF-файл]</a></div>\n</div>"
|
||||
},
|
||||
"document_pdf_public": {
|
||||
"collected": [
|
||||
[
|
||||
"pubchan",
|
||||
11,
|
||||
"pdf_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<div class=\"document-pdf\" style=\"padding: 10px;\">\n<a href=\"https://t.me/pubchan/11\" target=\"_blank\">[PDF-файл]</a></div>\n</div>"
|
||||
},
|
||||
"file_unique_id_none": {
|
||||
"collected": [],
|
||||
"html": "<div class=\"message-media\">"
|
||||
},
|
||||
"live_photo": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
14,
|
||||
"lp_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls autoplay loop muted src=\"http://test.example.com/media/testchan/14/lp_uid/7cbfcf53\"style=\"max-width:100%; width:auto; height:auto; max-height:400px;object-fit:contain;\"></video>\n</div>"
|
||||
},
|
||||
"paid_media": {
|
||||
"collected": [],
|
||||
"html": "<div class=\"message-media\">\n<div class=\"paid-media\">⭐ Paid media (50 stars, 2 item(s)) — available in Telegram</div>\n</div>"
|
||||
},
|
||||
"photo": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
1,
|
||||
"ph_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/1/ph_uid/08e6b2ef\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
|
||||
},
|
||||
"poll_media_img": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
17,
|
||||
"pl_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/17/pl_uid/492d238c\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
|
||||
},
|
||||
"poll_media_video": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
18,
|
||||
"plv_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/18/plv_uid/abb609a3\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
|
||||
},
|
||||
"sticker_img": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
9,
|
||||
"st_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/9/st_uid/03dc35d2\" alt=\"Sticker 😀\" style=\"max-width:100%;width:auto; height:auto; max-height:200px; object-fit:contain;\">\n</div>"
|
||||
},
|
||||
"sticker_video": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
10,
|
||||
"stv_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls autoplay loop muted src=\"http://test.example.com/media/testchan/10/stv_uid/cedd7494\"style=\"max-width:100%; width:auto; height:auto; max-height:200px;object-fit:contain;\"></video>\n</div>"
|
||||
},
|
||||
"story_photo": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
16,
|
||||
"sp_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<img src=\"http://test.example.com/media/testchan/16/sp_uid/d8a5d9c9\" style=\"max-width:100%; width:auto; height:auto;max-height:400px; object-fit:contain;\">\n</div>"
|
||||
},
|
||||
"story_video": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
15,
|
||||
"sv_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/15/sv_uid/1aba6362\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
|
||||
},
|
||||
"video": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
2,
|
||||
"vid_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/2/vid_uid/e0c6ba2e\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
|
||||
},
|
||||
"video_note": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
4,
|
||||
"vn_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<video controls src=\"http://test.example.com/media/testchan/4/vn_uid/a5ee9944\" style=\"max-width:100%; width:auto;height:auto; max-height:400px;\"></video>\n</div>"
|
||||
},
|
||||
"voice_default_mime": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
7,
|
||||
"vo_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/7/vo_uid/f16627e0\" type=\"audio/ogg\"></audio>\n<br>\n</div>"
|
||||
},
|
||||
"voice_explicit_mime": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
8,
|
||||
"vo2_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n<audio controls style=\"width:100%; max-width:400px;\"><source src=\"http://test.example.com/media/testchan/8/vo2_uid/30fed86a\" type=\"audio/wav\"></audio>\n<br>\n</div>"
|
||||
},
|
||||
"webpage_photo_long_text": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
22,
|
||||
"wpl_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n</div>"
|
||||
},
|
||||
"webpage_with_photo": {
|
||||
"collected": [
|
||||
[
|
||||
"testchan",
|
||||
20,
|
||||
"wp_uid"
|
||||
]
|
||||
],
|
||||
"html": "<div class=\"message-media\">\n</div>\n<div class=\"webpage-preview\">\n<div class=\"webpage-preview\" style=\"border-left: 3px solid #ccc; padding-left: 10px; margin: 10px 0;\">\n<div class=\"webpage-title\" style=\"font-weight:bold; margin:5px 0;\">\n<a href=\"https://example.com\" target=\"_blank\">Example</a></div>\n<div class=\"webpage-url\" style=\"color:#666; font-size:0.9em;margin-bottom:5px;\">https://example.com</div>\n<div class=\"webpage-photo\" style=\"margin-top:10px;\">\n<a href=\"https://example.com\" target=\"_blank\">\n<img src=\"http://test.example.com/media/testchan/20/wp_uid/a3dc7111\" style=\"max-width:100%; width:auto;height:auto; max-height:200px; object-fit:contain;\"></a></div>\n</div>\n</div>"
|
||||
},
|
||||
"webpage_without_photo": {
|
||||
"collected": [],
|
||||
"html": "<div class=\"message-media\">\n<div class=\"webpage-preview\">\n<div class=\"webpage-preview\" style=\"border-left: 3px solid #ccc; padding-left: 10px; margin: 10px 0;\">\n<div class=\"webpage-title\" style=\"font-weight:bold; margin:5px 0;\">\n<a href=\"https://example.com\" target=\"_blank\">Example</a></div>\n<div class=\"webpage-url\" style=\"color:#666; font-size:0.9em;margin-bottom:5px;\">https://example.com</div>\n</div>\n</div>"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
# flake8: noqa
|
||||
# pylint: disable=protected-access, missing-function-docstring, missing-class-docstring
|
||||
# pylint: disable=redefined-outer-name, line-too-long
|
||||
"""Stage-5 media table tests (render-pipeline refactor epic, issue #32/#34).
|
||||
|
||||
Two layers:
|
||||
* 5a — the MEDIA_SOURCES table + renderers reproduce the three old ladders
|
||||
(_get_file_unique_id, _save_media_file_ids, _generate_html_media) byte-for-byte:
|
||||
a fragment-level snapshot oracle (tests/test_data/media_fragments.json) plus
|
||||
table/selector/invariant unit tests.
|
||||
* 5b — the registered fixes (§3.13 large-file guard for every object, §3.14 the
|
||||
message-media div is closed in every branch) live in test_stage5b_media_fixes.py.
|
||||
|
||||
The cross-module invariant test pins the boundary with api_server.find_file_id_in_message:
|
||||
every object a selector returns must be resolvable there by its file_unique_id, or a new
|
||||
table entry would mint a /media URL the download path answers with 404.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from pyrogram.enums import MessageMediaType
|
||||
|
||||
from post_parser import (
|
||||
PostParser, MEDIA_SOURCES, RENDERERS, RenderCtx,
|
||||
_select_document, _select_sticker, _select_story, _select_poll_media,
|
||||
)
|
||||
from api_server import find_file_id_in_message
|
||||
from url_signer import KeyManager
|
||||
|
||||
from tests import media_fragment_replay as fr
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pin_signing_key(monkeypatch):
|
||||
# Deterministic media-URL digests regardless of checkout / cwd.
|
||||
monkeypatch.setattr(KeyManager, "signing_key", fr.FRAGMENT_SIGNING_KEY)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser():
|
||||
return PostParser(SimpleNamespace())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. Fragment-level snapshot oracle — the 5a byte-for-byte contract.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize("case_name", sorted(fr.build_cases().keys()))
|
||||
def test_media_fragment_matches_snapshot(parser, case_name):
|
||||
"""_generate_html_media (render ladder + _get_file_unique_id ladder) and the
|
||||
_save_media_file_ids collection ladder reproduce the frozen pre-refactor bytes."""
|
||||
snapshot = fr.load_snapshot()
|
||||
factory = fr.build_cases()[case_name]
|
||||
parser._pending_media_ids = []
|
||||
html = parser._generate_html_media(factory())
|
||||
collected = [[c, p, f] for c, p, f, _ in parser._pending_media_ids]
|
||||
assert html == snapshot[case_name]["html"], f"fragment HTML diverged for {case_name}"
|
||||
assert collected == snapshot[case_name]["collected"], f"collection diverged for {case_name}"
|
||||
|
||||
|
||||
def test_snapshot_covers_every_spec_case():
|
||||
"""Guard: the snapshot and the case builders stay in lockstep (a dropped case
|
||||
would otherwise silently reduce coverage)."""
|
||||
snapshot = fr.load_snapshot()
|
||||
assert set(snapshot.keys()) == set(fr.build_cases().keys())
|
||||
# The spec §5a "Шаг 0" edge branches must all be present.
|
||||
for required in ("photo", "video", "animation", "video_note", "audio_default_mime",
|
||||
"voice_default_mime", "sticker_img", "sticker_video",
|
||||
"document_pdf_public", "document_pdf_private", "document_normal",
|
||||
"live_photo", "story_video", "story_photo", "poll_media_img",
|
||||
"poll_media_video", "paid_media", "webpage_with_photo",
|
||||
"webpage_without_photo", "webpage_photo_long_text",
|
||||
"file_unique_id_none", "channel_username_none"):
|
||||
assert required in snapshot, f"missing spec fragment case: {required}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. Table structure: every render kind has a renderer; the only kind=None entry
|
||||
# is WEB_PAGE; PAID_MEDIA has no entry.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_every_render_kind_has_a_renderer():
|
||||
# Fixed-kind lambda entries (branchy selectors are covered by the selector unit
|
||||
# tests below and the fragment oracle).
|
||||
static = {
|
||||
MessageMediaType.PHOTO: 'img_400',
|
||||
MessageMediaType.VIDEO: 'video_400',
|
||||
MessageMediaType.ANIMATION: 'video_400',
|
||||
MessageMediaType.VIDEO_NOTE: 'video_400',
|
||||
MessageMediaType.AUDIO: 'audio',
|
||||
MessageMediaType.VOICE: 'audio',
|
||||
MessageMediaType.LIVE_PHOTO: 'video_loop_400',
|
||||
}
|
||||
for mt, expected_kind in static.items():
|
||||
assert expected_kind in RENDERERS, f"{mt} kind {expected_kind} has no renderer"
|
||||
# Selector-produced kinds (document/sticker/story/poll) all resolve to renderers
|
||||
# or, for WEB_PAGE, to None.
|
||||
for kind in ('img_400', 'video_400', 'audio', 'pdf', 'video_loop_200',
|
||||
'img_200_sticker', 'video_loop_400'):
|
||||
assert kind in RENDERERS
|
||||
|
||||
|
||||
def test_web_page_is_the_only_kind_none_entry():
|
||||
assert MEDIA_SOURCES[MessageMediaType.WEB_PAGE](SimpleNamespace(web_page=None))[1] is None
|
||||
|
||||
|
||||
def test_paid_media_has_no_table_entry():
|
||||
assert MessageMediaType.PAID_MEDIA not in MEDIA_SOURCES
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. Selector unit tests (the branchy selectors).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_select_document_pdf_vs_image():
|
||||
pdf = SimpleNamespace(document=SimpleNamespace(mime_type="application/pdf", file_unique_id="d1"))
|
||||
png = SimpleNamespace(document=SimpleNamespace(mime_type="image/png", file_unique_id="d2"))
|
||||
assert _select_document(pdf)[1] == 'pdf'
|
||||
assert _select_document(png)[1] == 'img_400'
|
||||
assert _select_document(pdf)[0] is pdf.document
|
||||
|
||||
|
||||
def test_select_sticker_video_vs_image():
|
||||
vid = SimpleNamespace(sticker=SimpleNamespace(is_video=True, file_unique_id="s1"))
|
||||
img = SimpleNamespace(sticker=SimpleNamespace(is_video=False, file_unique_id="s2"))
|
||||
assert _select_sticker(vid)[1] == 'video_loop_200'
|
||||
assert _select_sticker(img)[1] == 'img_200_sticker'
|
||||
|
||||
|
||||
def test_select_story_maps_helper_kind():
|
||||
vid = SimpleNamespace(story=SimpleNamespace(video=SimpleNamespace(file_unique_id="v"), photo=None))
|
||||
pic = SimpleNamespace(story=SimpleNamespace(video=None, photo=SimpleNamespace(file_unique_id="p")))
|
||||
none = SimpleNamespace(story=None)
|
||||
assert _select_story(vid)[1] == 'video_400'
|
||||
assert _select_story(pic)[1] == 'img_400'
|
||||
assert _select_story(none) == (None, None)
|
||||
|
||||
|
||||
def test_select_poll_media_maps_helper_kind():
|
||||
img = SimpleNamespace(poll=SimpleNamespace(description_media=SimpleNamespace(photo=SimpleNamespace(file_unique_id="p"))))
|
||||
vid = SimpleNamespace(poll=SimpleNamespace(description_media=SimpleNamespace(video=SimpleNamespace(file_unique_id="v"))))
|
||||
none = SimpleNamespace(poll=None)
|
||||
assert _select_poll_media(img)[1] == 'img_400'
|
||||
assert _select_poll_media(vid)[1] == 'video_400'
|
||||
assert _select_poll_media(none) == (None, None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4. Renderer byte structure (audio emits two items; pdf emits its two-append block).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_audio_renderer_emits_tag_and_br():
|
||||
out = RENDERERS['audio'](RenderCtx(url="U", mime="audio/mpeg"))
|
||||
assert len(out) == 2 and out[1] == '<br>'
|
||||
assert 'type="audio/mpeg"' in out[0]
|
||||
|
||||
|
||||
def test_pdf_renderer_emits_two_item_block():
|
||||
out = RENDERERS['pdf'](RenderCtx(url="U", tg_link="https://t.me/x/1"))
|
||||
assert len(out) == 2
|
||||
assert out[0] == '<div class="document-pdf" style="padding: 10px;">'
|
||||
assert out[1] == '<a href="https://t.me/x/1" target="_blank">[PDF-файл]</a></div>'
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5. Cross-module invariant: every selector object is resolvable in api_server.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _invariant_messages():
|
||||
"""One mock per MEDIA_SOURCES type whose selected object carries a real
|
||||
file_unique_id/file_id, so find_file_id_in_message can resolve it."""
|
||||
def base(**extra):
|
||||
m = SimpleNamespace(id=1, chat=SimpleNamespace(id=-100, username="c"), web_page=None, poll=None)
|
||||
for a in ("photo", "video", "document", "audio", "voice", "video_note",
|
||||
"animation", "sticker"):
|
||||
setattr(m, a, None)
|
||||
for k, v in extra.items():
|
||||
setattr(m, k, v)
|
||||
return m
|
||||
|
||||
media = SimpleNamespace(file_unique_id="UID", file_id="FID")
|
||||
cases = {
|
||||
MessageMediaType.PHOTO: base(media=MessageMediaType.PHOTO, photo=media),
|
||||
MessageMediaType.VIDEO: base(media=MessageMediaType.VIDEO, video=media),
|
||||
MessageMediaType.ANIMATION: base(media=MessageMediaType.ANIMATION, animation=media),
|
||||
MessageMediaType.VIDEO_NOTE: base(media=MessageMediaType.VIDEO_NOTE, video_note=media),
|
||||
MessageMediaType.AUDIO: base(media=MessageMediaType.AUDIO, audio=media),
|
||||
MessageMediaType.VOICE: base(media=MessageMediaType.VOICE, voice=media),
|
||||
MessageMediaType.DOCUMENT: base(media=MessageMediaType.DOCUMENT,
|
||||
document=SimpleNamespace(mime_type="image/png", file_unique_id="UID", file_id="FID")),
|
||||
MessageMediaType.STICKER: base(media=MessageMediaType.STICKER,
|
||||
sticker=SimpleNamespace(is_video=False, file_unique_id="UID", file_id="FID")),
|
||||
MessageMediaType.LIVE_PHOTO: base(media=MessageMediaType.LIVE_PHOTO, live_photo=media),
|
||||
MessageMediaType.STORY: base(media=MessageMediaType.STORY,
|
||||
story=SimpleNamespace(video=media, photo=None)),
|
||||
MessageMediaType.POLL: base(media=MessageMediaType.POLL,
|
||||
poll=SimpleNamespace(description_media=SimpleNamespace(photo=media))),
|
||||
MessageMediaType.WEB_PAGE: base(media=MessageMediaType.WEB_PAGE,
|
||||
web_page=SimpleNamespace(photo=media)),
|
||||
}
|
||||
return cases
|
||||
|
||||
|
||||
@pytest.mark.parametrize("media_type", list(MEDIA_SOURCES.keys()))
|
||||
async def test_selector_object_is_resolvable_in_api_server(media_type):
|
||||
"""The object MEDIA_SOURCES selects is found by api_server.find_file_id_in_message
|
||||
via its file_unique_id — the /media download path can always resolve a URL the
|
||||
table produced (spec §5a inter-module invariant)."""
|
||||
message = _invariant_messages()[media_type]
|
||||
selected_obj, _kind = MEDIA_SOURCES[media_type](message)
|
||||
file_unique_id = getattr(selected_obj, "file_unique_id", None)
|
||||
assert file_unique_id, f"{media_type} selector returned no usable file_unique_id"
|
||||
resolved = await find_file_id_in_message(message, file_unique_id)
|
||||
assert resolved == getattr(selected_obj, "file_id", None), \
|
||||
f"{media_type}: selected object not resolvable in find_file_id_in_message"
|
||||
|
||||
|
||||
def test_media_sources_covers_all_old_ladder_types():
|
||||
"""Every type the pre-refactor _get_file_unique_id dict handled is in the table."""
|
||||
old_types = {
|
||||
MessageMediaType.PHOTO, MessageMediaType.VIDEO, MessageMediaType.DOCUMENT,
|
||||
MessageMediaType.AUDIO, MessageMediaType.VOICE, MessageMediaType.VIDEO_NOTE,
|
||||
MessageMediaType.ANIMATION, MessageMediaType.STICKER, MessageMediaType.WEB_PAGE,
|
||||
MessageMediaType.LIVE_PHOTO, MessageMediaType.STORY, MessageMediaType.POLL,
|
||||
}
|
||||
assert old_types <= set(MEDIA_SOURCES.keys())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 6. /flags endpoint constraint: flags.append(...) stays inside _extract_flags so
|
||||
# inspect.getsource still discovers them.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_get_all_possible_flags_nonempty_and_known():
|
||||
flags = PostParser.get_all_possible_flags()
|
||||
assert flags, "flag introspection returned nothing"
|
||||
for known in ("video", "audio", "no_image", "sticker", "poll", "fwd"):
|
||||
assert known in flags, f"known flag {known} not discovered by /flags introspection"
|
||||
Reference in New Issue
Block a user