"""测试 HN enrich(Algolia 评论树 + 外链正文)""" import sys from pathlib import Path from unittest.mock import patch, AsyncMock, MagicMock import pytest sys.path.insert(0, str(Path(__file__).parent.parent.parent)) from src.sections.hackernews.item_enricher import enrich_story def _kwargs(**overrides): base = dict( top_comments=3, top_l2_per_l1=2, comment_max_chars=500, comments_total_chars=60000, link_content_max_chars=3000, algolia_base="https://hn.algolia.com/api/v1", timeout=10, ) base.update(overrides) return base @pytest.mark.asyncio async def test_enrich_external_link_story_returns_tree(): story = { "id": "111", "title": "T", "url": "https://example.com/post", "site": "example.com", "points": 100, "comments": 5, "comments_url": "https://news.ycombinator.com/item?id=111", } algolia_payload = { "text": None, "children": [ { "text": "

comment one

", "children": [ {"text": "

reply 1a

"}, {"text": "

reply 1b

"}, {"text": "

reply 1c (should be dropped)

"}, ], }, {"text": "

comment two

", "children": []}, {"text": "

comment three

"}, {"text": "

comment four (over top_comments cap)

"}, ], } async def fake_algolia(session, item_id, **kw): return algolia_payload async def fake_external(session, url, **kw): return "link body" with patch( "src.sections.hackernews.item_enricher._fetch_algolia_item", new=AsyncMock(side_effect=fake_algolia), ), patch( "src.sections.hackernews.item_enricher._fetch_external_markdown", new=AsyncMock(side_effect=fake_external), ): enriched = await enrich_story( session=MagicMock(), story=story, **_kwargs() ) tree = enriched["top_comments"] assert len(tree) == 3 assert "comment one" in tree[0]["l1"] assert len(tree[0]["replies"]) == 2 assert "reply 1a" in tree[0]["replies"][0] assert "reply 1b" in tree[0]["replies"][1] assert tree[1]["replies"] == [] assert tree[2]["replies"] == [] assert "link body" in enriched["link_content"] @pytest.mark.asyncio async def test_enrich_show_hn_uses_root_text_no_external_fetch(): story = { "id": "222", "title": "Show HN: T", "url": "https://news.ycombinator.com/item?id=222", "site": "", "points": 200, "comments": 10, "comments_url": "https://news.ycombinator.com/item?id=222", } algolia_payload = { "text": "

post body text

", "children": [{"text": "

c1

"}], } link_calls = [] async def fake_algolia(session, item_id, **kw): return algolia_payload async def fake_external(session, url, **kw): link_calls.append(url) return "should not be called" with patch( "src.sections.hackernews.item_enricher._fetch_algolia_item", new=AsyncMock(side_effect=fake_algolia), ), patch( "src.sections.hackernews.item_enricher._fetch_external_markdown", new=AsyncMock(side_effect=fake_external), ): enriched = await enrich_story( session=MagicMock(), story=story, **_kwargs() ) assert link_calls == [] assert "post body text" in enriched["link_content"] assert enriched["top_comments"][0]["l1"].startswith("c1") or "c1" in enriched["top_comments"][0]["l1"] @pytest.mark.asyncio async def test_enrich_truncates_comments_and_link(): story = { "id": "333", "title": "T", "url": "https://example.com/a", "site": "example.com", "points": 100, "comments": 2, "comments_url": "x", } long_comment = "

" + ("y" * 2000) + "

" long_reply = "

" + ("z" * 2000) + "

" long_link = "z" * 5000 async def fake_algolia(session, item_id, **kw): return { "text": None, "children": [ {"text": long_comment, "children": [{"text": long_reply}]} ], } async def fake_external(session, url, **kw): return long_link with patch( "src.sections.hackernews.item_enricher._fetch_algolia_item", new=AsyncMock(side_effect=fake_algolia), ), patch( "src.sections.hackernews.item_enricher._fetch_external_markdown", new=AsyncMock(side_effect=fake_external), ): enriched = await enrich_story( session=MagicMock(), story=story, **_kwargs(comment_max_chars=100, link_content_max_chars=200), ) assert len(enriched["top_comments"][0]["l1"]) <= 100 assert len(enriched["top_comments"][0]["replies"][0]) <= 100 assert len(enriched["link_content"]) <= 200 @pytest.mark.asyncio async def test_enrich_failure_returns_partial(): story = { "id": "444", "title": "T", "url": "https://example.com/x", "site": "example.com", "points": 100, "comments": 2, "comments_url": "x", } async def fake_algolia(session, item_id, **kw): raise RuntimeError("algolia down") async def fake_external(session, url, **kw): return "ok" with patch( "src.sections.hackernews.item_enricher._fetch_algolia_item", new=AsyncMock(side_effect=fake_algolia), ), patch( "src.sections.hackernews.item_enricher._fetch_external_markdown", new=AsyncMock(side_effect=fake_external), ): enriched = await enrich_story( session=MagicMock(), story=story, **_kwargs() ) assert enriched["top_comments"] == [] assert "ok" in enriched["link_content"] @pytest.mark.asyncio async def test_enrich_total_budget_stops_early(): """累计字符达 comments_total_chars 立即停止,后续 L1 / L2 都不再加入""" story = { "id": "555", "title": "T", "url": "https://example.com/q", "site": "example.com", "points": 100, "comments": 5, "comments_url": "x", } big = "

" + ("a" * 500) + "

" # markdown 约 500 chars async def fake_algolia(session, item_id, **kw): return { "text": None, "children": [{"text": big, "children": [{"text": big}, {"text": big}]} for _ in range(10)], } async def fake_external(session, url, **kw): return "x" with patch( "src.sections.hackernews.item_enricher._fetch_algolia_item", new=AsyncMock(side_effect=fake_algolia), ), patch( "src.sections.hackernews.item_enricher._fetch_external_markdown", new=AsyncMock(side_effect=fake_external), ): enriched = await enrich_story( session=MagicMock(), story=story, **_kwargs( top_comments=10, top_l2_per_l1=2, comment_max_chars=500, comments_total_chars=1500, ), ) tree = enriched["top_comments"] total = sum(len(n["l1"]) + sum(len(r) for r in n["replies"]) for n in tree) # 累计应该在 1500 附近停下(允许多收一条到 ~2000),不应该收全 10*3=30 条 assert total <= 2000 assert len(tree) < 10