feat: 早报系统重构与功能增强
- 新增常驻调度器 daily/scheduler.py + run-scheduler.ps1(定时生成/推送) - 新增 daily/bridge_manager.py:Windows 兼容的 Cursor SDK 桥接 - 新增 skills/daily-featured-pick 首推 Skill 与叙事轴/去重逻辑 - 新闻抓取窗口、GitHub 搜索、企微 delta 模式等多项改进 - 补充设计文档与 superpowers 计划/规范 - 新增对应测试(scheduler、featured_pick、github_search、news_fetch_window 等) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
117
tests/test_ai_news_research.py
Normal file
117
tests/test_ai_news_research.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""AI 时讯 deep-research 解析与企微格式。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from daily.format_wecom import _ai_news_lines, replace_wecom_news_sections
|
||||
from daily.news.research import parse_research_response
|
||||
|
||||
|
||||
class TestAiNewsResearchParse(unittest.TestCase):
|
||||
def test_parse_items(self):
|
||||
raw = """
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"title": "Apple sues OpenAI",
|
||||
"link": "https://techcrunch.com/2026/07/10/apple/",
|
||||
"source_name": "TechCrunch",
|
||||
"desc_short": "苹果起诉 OpenAI 涉嫌窃取商业机密",
|
||||
"published_fmt": "07-11 05:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
items, tech = parse_research_response(raw, limit=10)
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(items[0]["source_name"], "TechCrunch")
|
||||
self.assertIn("苹果", items[0]["desc_short"])
|
||||
self.assertEqual(tech, [])
|
||||
|
||||
def test_dedupe_links(self):
|
||||
raw = """{"items": [
|
||||
{"title": "A", "link": "https://example.com/a", "source_name": "Ex", "desc_short": "一"},
|
||||
{"title": "B", "link": "https://example.com/a", "source_name": "Ex", "desc_short": "二"}
|
||||
]}"""
|
||||
items, _ = parse_research_response(raw, limit=10)
|
||||
self.assertEqual(len(items), 1)
|
||||
|
||||
def test_parse_tech_items(self):
|
||||
raw = """{"items": [
|
||||
{"title": "A", "link": "https://example.com/a", "source_name": "Ex", "desc_short": "一"}
|
||||
], "tech_items": [
|
||||
{"title": "B", "link": "https://example.com/b", "source_name": "Ex", "desc_short": "二"}
|
||||
]}"""
|
||||
items, tech = parse_research_response(raw, limit=10, tech_limit=5)
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertEqual(len(tech), 1)
|
||||
self.assertEqual(tech[0]["title"], "B")
|
||||
|
||||
|
||||
class TestMergedWecomNews(unittest.TestCase):
|
||||
def test_merged_block_format(self):
|
||||
items = [
|
||||
{
|
||||
"title": "Apple sues OpenAI",
|
||||
"link": "https://techcrunch.com/x",
|
||||
"source_name": "TechCrunch",
|
||||
"desc_short": "苹果起诉 OpenAI",
|
||||
"published_fmt": "07-11 05:00",
|
||||
}
|
||||
]
|
||||
lines = _ai_news_lines(items, merged=True)
|
||||
self.assertIn("TechCrunch - Apple sues OpenAI", lines[0])
|
||||
self.assertIn("— 苹果起诉 OpenAI", lines[0])
|
||||
self.assertNotIn("07-11", lines[0])
|
||||
|
||||
def test_merged_with_tech_block(self):
|
||||
md = """📰 **早报**
|
||||
|
||||
📈 **Skills Trending Top 1**
|
||||
1. skill
|
||||
"""
|
||||
main = [
|
||||
{"title": "Main", "link": "https://example.com/m", "source_name": "Src", "desc_short": "主条", "published_fmt": "07-11"}
|
||||
]
|
||||
tech = [
|
||||
{"title": "Tech", "link": "https://example.com/t", "source_name": "Src2", "desc_short": "技术条", "published_fmt": "07-12"}
|
||||
]
|
||||
out = replace_wecom_news_sections(md, ai_news=main, tech_ai_news=tech, merged=True)
|
||||
self.assertIn("📰 **AI 时讯精选 Top 2**", out)
|
||||
self.assertNotIn("技术类时讯", out)
|
||||
self.assertNotIn("🔧", out)
|
||||
self.assertNotIn("07-11", out)
|
||||
self.assertNotIn("07-12", out)
|
||||
self.assertIn("2. [Src2 - Tech]", out)
|
||||
|
||||
def test_replace_merged_removes_split_blocks(self):
|
||||
md = """📰 **早报**
|
||||
|
||||
🌍 **国际 AI 时讯 Top 1**
|
||||
1. [old](https://example.com/old) · `X`
|
||||
|
||||
🇨🇳 **国内 AI 时讯 Top 1**
|
||||
1. [old2](https://example.com/old2) · `Y`
|
||||
|
||||
📈 **Skills Trending Top 1**
|
||||
1. skill
|
||||
"""
|
||||
items = [
|
||||
{
|
||||
"title": "New story",
|
||||
"link": "https://example.com/new",
|
||||
"source_name": "Fortune",
|
||||
"desc_short": "新故事",
|
||||
"published_fmt": "",
|
||||
}
|
||||
]
|
||||
out = replace_wecom_news_sections(md, ai_news=items, merged=True)
|
||||
self.assertIn("📰 **AI 时讯精选 Top 1**", out)
|
||||
self.assertNotIn("国际 AI 时讯", out)
|
||||
self.assertNotIn("国内 AI 时讯", out)
|
||||
self.assertIn("📈 **Skills Trending Top 1**", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -27,6 +27,19 @@ class ShownKeysTests(unittest.TestCase):
|
||||
items = [{"repo": "a/b"}, {"repo": "c/d"}]
|
||||
self.assertEqual(extract_shown_keys("github_trending", items), ["a/b", "c/d"])
|
||||
|
||||
def test_extract_skill_keys_include_source(self):
|
||||
items = [
|
||||
{
|
||||
"id": "open.feishu.cn/lark-drive",
|
||||
"source": "open.feishu.cn",
|
||||
"title": "lark-drive",
|
||||
}
|
||||
]
|
||||
self.assertEqual(
|
||||
extract_shown_keys("skills_trending", items),
|
||||
["open.feishu.cn/lark-drive", "open.feishu.cn"],
|
||||
)
|
||||
|
||||
def test_load_recent_reads_wecom_shown_not_baseline(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp)
|
||||
@@ -48,6 +61,41 @@ class ShownKeysTests(unittest.TestCase):
|
||||
self.assertEqual(keys["github_trending"], {"x/y"})
|
||||
self.assertNotIn("a/b", keys["github_trending"])
|
||||
|
||||
def test_load_recent_falls_back_to_wecom_md_when_shown_missing(self):
|
||||
"""旧日 data 无 wecom_shown_keys 时,从同日 wecom.md 解析实际展示 keys。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp)
|
||||
payload = {
|
||||
"data": {
|
||||
"date": "2026-07-13",
|
||||
"github_trending": [{"repo": "other/top"}],
|
||||
}
|
||||
}
|
||||
(out / "2026-07-13.data.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
(out / "2026-07-13.wecom.md").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"🐙 **GitHub Trending Top 2**",
|
||||
"1. [vinta/awesome-python](https://github.com/vinta/awesome-python)",
|
||||
"2. [react/react](https://github.com/react/react)",
|
||||
"",
|
||||
"🌱 **GitHub 新兴 Top 1**",
|
||||
"1. [elder-plinius/T3MP3ST](https://github.com/elder-plinius/T3MP3ST)",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("daily.board_history.OUTPUT_DIR", out):
|
||||
keys = load_recent_shown_keys("2026-07-14", lookback_days=7)
|
||||
self.assertEqual(
|
||||
keys["github_trending"],
|
||||
{"vinta/awesome-python", "react/react"},
|
||||
)
|
||||
self.assertEqual(keys["github_emerging"], {"elder-plinius/T3MP3ST"})
|
||||
self.assertNotIn("other/top", keys["github_trending"])
|
||||
|
||||
def test_merge_shown_does_not_touch_baseline(self):
|
||||
data = {
|
||||
"movement_baseline": {"github_trending": [{"repo": "raw/one"}]},
|
||||
|
||||
@@ -53,6 +53,39 @@ class BoardSelectTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual([x["id"] for x in out], ["c/d/s2"])
|
||||
|
||||
def test_skill_filters_recent_by_source(self):
|
||||
items = [
|
||||
{"id": "a/b/s-new", "source": "a/b", "title": "s-new", "installs": 10},
|
||||
{"id": "c/d/s2", "source": "c/d", "title": "s2", "installs": 9},
|
||||
]
|
||||
out = board_select(
|
||||
board="skills_hot",
|
||||
items=items,
|
||||
recent_keys={"a/b"}, # source-level history
|
||||
limit=10,
|
||||
pool_size=50,
|
||||
kind="skill",
|
||||
)
|
||||
self.assertEqual([x["id"] for x in out], ["c/d/s2"])
|
||||
|
||||
def test_skill_filters_recent_skill_id_as_same_source(self):
|
||||
from daily.format_wecom import expand_skill_recent_keys
|
||||
|
||||
items = [
|
||||
{"id": "open.feishu.cn/lark-drive", "source": "open.feishu.cn", "title": "lark-drive", "installs": 10},
|
||||
{"id": "fresh/src/s", "source": "fresh/src", "title": "s", "installs": 9},
|
||||
]
|
||||
recent = expand_skill_recent_keys({"open.feishu.cn/lark-doc"})
|
||||
out = board_select(
|
||||
board="skills_trending",
|
||||
items=items,
|
||||
recent_keys=recent,
|
||||
limit=10,
|
||||
pool_size=50,
|
||||
kind="skill",
|
||||
)
|
||||
self.assertEqual([x["id"] for x in out], ["fresh/src/s"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
95
tests/test_featured_pick.py
Normal file
95
tests/test_featured_pick.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Tests for daily.featured_pick."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from daily.featured_pick import (
|
||||
apply_featured_pick,
|
||||
match_in_data,
|
||||
parse_featured_pick,
|
||||
pick_command_from_featured,
|
||||
pick_why_from_featured,
|
||||
research_featured_pick,
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_INPUT = {
|
||||
"skills_trending": [
|
||||
{
|
||||
"id": "foo/bar/gstack-cli",
|
||||
"title": "gstack-cli",
|
||||
"source": "foo/bar",
|
||||
"installs": 1200,
|
||||
"installs_fmt": "1.2K",
|
||||
"link": "https://skills.sh/foo/bar/gstack-cli",
|
||||
"description": "Agent workflow CLI",
|
||||
}
|
||||
],
|
||||
"skills_hot": [],
|
||||
"github_trending": [
|
||||
{
|
||||
"repo": "acme/gstack",
|
||||
"url": "https://github.com/acme/gstack",
|
||||
"total_stars_fmt": "3.2K",
|
||||
"description": "GStack toolkit",
|
||||
}
|
||||
],
|
||||
"github_emerging": [],
|
||||
"github_topic": {"topic": "llm", "repos": []},
|
||||
}
|
||||
|
||||
|
||||
class ParseFeaturedPickTests(unittest.TestCase):
|
||||
def test_empty(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertIsNone(parse_featured_pick())
|
||||
|
||||
def test_query_only(self):
|
||||
with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack"}, clear=True):
|
||||
self.assertEqual(parse_featured_pick(), {"query": "gstack"})
|
||||
|
||||
def test_query_with_url(self):
|
||||
with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack|https://example.com"}, clear=True):
|
||||
self.assertEqual(
|
||||
parse_featured_pick(),
|
||||
{"query": "gstack", "url_hint": "https://example.com"},
|
||||
)
|
||||
|
||||
|
||||
class MatchInDataTests(unittest.TestCase):
|
||||
def test_matches_skill_and_github(self):
|
||||
matches = match_in_data(SAMPLE_INPUT, "gstack")
|
||||
self.assertEqual(len(matches["skills"]), 1)
|
||||
self.assertEqual(matches["skills"][0]["title"], "gstack-cli")
|
||||
self.assertEqual(len(matches["github"]), 1)
|
||||
self.assertEqual(matches["github"][0]["repo"], "acme/gstack")
|
||||
|
||||
|
||||
class FeaturedPickWorkflowTests(unittest.TestCase):
|
||||
def test_fallback_without_llm(self):
|
||||
llm_input = dict(SAMPLE_INPUT)
|
||||
with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack"}, clear=True):
|
||||
with patch("daily.featured_pick.has_llm_configured", return_value=False):
|
||||
featured = research_featured_pick(llm_input, date_str="2026-07-03")
|
||||
self.assertIsNotNone(featured)
|
||||
assert featured is not None
|
||||
self.assertEqual(featured["type"], "skill")
|
||||
self.assertIn("npx skills add foo/bar/gstack-cli", featured["command"])
|
||||
self.assertTrue(featured["why_today"])
|
||||
|
||||
def test_apply_featured_pick_mutates_input(self):
|
||||
llm_input = dict(SAMPLE_INPUT)
|
||||
with patch.dict(os.environ, {"DAILY_FEATURED_PICK": "gstack"}, clear=True):
|
||||
with patch("daily.featured_pick.has_llm_configured", return_value=False):
|
||||
featured = apply_featured_pick(llm_input, date_str="2026-07-03")
|
||||
self.assertIsNotNone(featured)
|
||||
self.assertIn("featured_pick", llm_input)
|
||||
self.assertEqual(pick_command_from_featured(featured), llm_input["featured_pick"]["command"])
|
||||
self.assertEqual(pick_why_from_featured(featured), llm_input["featured_pick"]["why_today"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,10 +1,19 @@
|
||||
# tests/test_featured_resolve.py
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from daily.featured_pick import featured_identity_key, featured_resolve
|
||||
from daily.featured_pick import (
|
||||
featured_identity_key,
|
||||
featured_resolve,
|
||||
load_recent_featured_keys,
|
||||
load_yesterday_featured_key,
|
||||
)
|
||||
|
||||
|
||||
class FeaturedResolveTests(unittest.TestCase):
|
||||
@@ -78,6 +87,46 @@ class FeaturedResolveTests(unittest.TestCase):
|
||||
"foo/bar",
|
||||
)
|
||||
|
||||
def test_load_yesterday_falls_back_to_featured_pick(self):
|
||||
"""缺 featured_pick_key 时从 featured_pick.url 推导身份,避免连日重复首推。"""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp)
|
||||
payload = {
|
||||
"data": {
|
||||
"date": "2026-07-13",
|
||||
"featured_pick": {
|
||||
"type": "github",
|
||||
"title": "headroom",
|
||||
"url": "https://github.com/headroomlabs-ai/headroom",
|
||||
},
|
||||
}
|
||||
}
|
||||
(out / "2026-07-13.data.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
with patch("daily.featured_pick.OUTPUT_DIR", out):
|
||||
key = load_yesterday_featured_key("2026-07-14")
|
||||
self.assertEqual(key, "headroomlabs-ai/headroom")
|
||||
|
||||
def test_load_recent_falls_back_to_featured_pick(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp)
|
||||
payload = {
|
||||
"data": {
|
||||
"date": "2026-07-13",
|
||||
"featured_pick": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/headroomlabs-ai/headroom",
|
||||
},
|
||||
}
|
||||
}
|
||||
(out / "2026-07-13.data.json").write_text(
|
||||
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
with patch("daily.featured_pick.OUTPUT_DIR", out):
|
||||
keys = load_recent_featured_keys("2026-07-14", days=7)
|
||||
self.assertIn("headroomlabs-ai/headroom", keys)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
71
tests/test_github_search.py
Normal file
71
tests/test_github_search.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Tests for GitHub Search pagination / deep pool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class SearchGithubReposPaginationTests(unittest.TestCase):
|
||||
def test_search_paginates_beyond_first_page_of_30(self):
|
||||
from daily.github.search import search_github_repos
|
||||
|
||||
def make_items(start: int, n: int) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"full_name": f"org/repo{i}",
|
||||
"html_url": f"https://github.com/org/repo{i}",
|
||||
"description": f"desc {i}",
|
||||
"language": "Python",
|
||||
"stargazers_count": 1000 - i,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
}
|
||||
for i in range(start, start + n)
|
||||
]
|
||||
|
||||
responses = [
|
||||
MagicMock(status_code=200, json=lambda: {"items": make_items(1, 100)}),
|
||||
MagicMock(status_code=200, json=lambda: {"items": make_items(101, 50)}),
|
||||
]
|
||||
client = MagicMock()
|
||||
client.__enter__.return_value = client
|
||||
client.__exit__.return_value = False
|
||||
client.get.side_effect = responses
|
||||
|
||||
with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}, clear=False):
|
||||
with patch("daily.github.search.httpx.Client", return_value=client):
|
||||
with patch("daily.github.search.github_token", return_value="test-token"):
|
||||
repos = search_github_repos("stars:>50", 120, require_token=True)
|
||||
|
||||
self.assertEqual(len(repos), 120)
|
||||
self.assertEqual(repos[0]["repo"], "org/repo1")
|
||||
self.assertEqual(repos[119]["repo"], "org/repo120")
|
||||
self.assertEqual(client.get.call_count, 2)
|
||||
first_params = client.get.call_args_list[0].kwargs["params"]
|
||||
self.assertEqual(first_params["per_page"], 100)
|
||||
self.assertEqual(first_params["page"], 1)
|
||||
|
||||
|
||||
class GithubBoardDeepPoolTests(unittest.TestCase):
|
||||
def test_board_select_fills_ten_when_deep_pool_has_fresh_repos(self):
|
||||
from daily.board_select import board_select
|
||||
|
||||
recent = {f"old/r{i}" for i in range(1, 33)}
|
||||
items = [{"repo": f"old/r{i}"} for i in range(1, 31)] + [
|
||||
{"repo": f"fresh/r{i}"} for i in range(1, 20)
|
||||
]
|
||||
selected = board_select(
|
||||
board="github_trending",
|
||||
items=items,
|
||||
recent_keys=recent,
|
||||
limit=10,
|
||||
pool_size=100,
|
||||
kind="github",
|
||||
)
|
||||
self.assertEqual(len(selected), 10)
|
||||
self.assertTrue(all(r["repo"].startswith("fresh/") for r in selected))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
156
tests/test_news_fetch_window.py
Normal file
156
tests/test_news_fetch_window.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Tests for news time window filtering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import patch
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from daily.news.fetch import _cutoff_datetime, _parse_datetime, _within_window
|
||||
|
||||
|
||||
class NewsWindowTests(unittest.TestCase):
|
||||
def test_cutoff_floor_today_excludes_yesterday_even_within_24h(self):
|
||||
tz = ZoneInfo("Asia/Shanghai")
|
||||
# 2026-07-09 09:00 CST = 2026-07-09 01:00 UTC
|
||||
fixed = datetime(2026, 7, 9, 1, 0, tzinfo=timezone.utc)
|
||||
with patch("daily.news.fetch._now_utc", return_value=fixed):
|
||||
with patch.dict(os.environ, {"DAILY_AI_NEWS_HOURS": "24"}, clear=False):
|
||||
cutoff = _cutoff_datetime(floor_today=True)
|
||||
start_today_cst = datetime(2026, 7, 9, 0, 0, tzinfo=tz).astimezone(timezone.utc)
|
||||
self.assertEqual(cutoff, start_today_cst)
|
||||
yesterday = datetime(2026, 7, 8, 20, 0, tzinfo=tz).astimezone(timezone.utc)
|
||||
self.assertFalse(_within_window({"published": yesterday.isoformat()}, cutoff))
|
||||
|
||||
def test_cutoff_rolling_only_includes_last_24h(self):
|
||||
fixed = datetime(2026, 7, 9, 12, 0, tzinfo=timezone.utc)
|
||||
with patch("daily.news.fetch._now_utc", return_value=fixed):
|
||||
with patch.dict(os.environ, {"DAILY_AI_NEWS_HOURS": "24"}, clear=False):
|
||||
cutoff = _cutoff_datetime(floor_today=False)
|
||||
self.assertEqual(cutoff, fixed - timedelta(hours=24))
|
||||
|
||||
def test_within_window_rejects_missing_datetime(self):
|
||||
cutoff = datetime(2026, 7, 9, 0, 0, tzinfo=timezone.utc)
|
||||
self.assertFalse(_within_window({"title": "x", "link": "https://a.com"}, cutoff))
|
||||
|
||||
def test_parse_date_only_uses_local_noon(self):
|
||||
with patch.dict(os.environ, {"DAILY_AI_NEWS_TZ": "Asia/Shanghai"}, clear=False):
|
||||
dt = _parse_datetime("2026-07-09")
|
||||
self.assertIsNotNone(dt)
|
||||
assert dt is not None
|
||||
local = dt.astimezone(ZoneInfo("Asia/Shanghai"))
|
||||
self.assertEqual(local.hour, 12)
|
||||
|
||||
|
||||
class NewsFormatTests(unittest.TestCase):
|
||||
def test_ai_news_lines_use_desc_as_link_text(self):
|
||||
from daily.format_wecom import _ai_news_lines
|
||||
|
||||
lines = _ai_news_lines(
|
||||
[
|
||||
{
|
||||
"title": "English Title",
|
||||
"link": "https://example.com/a",
|
||||
"source_name": "Src",
|
||||
"published_fmt": "07-11",
|
||||
"desc_short": "中文摘要一句",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(len(lines), 1)
|
||||
self.assertIn("[中文摘要一句](https://example.com/a)", lines[0])
|
||||
self.assertNotIn("English Title", lines[0])
|
||||
self.assertNotIn("> ", lines[0])
|
||||
|
||||
def test_ai_news_lines_fallback_to_title(self):
|
||||
from daily.format_wecom import _ai_news_lines
|
||||
|
||||
lines = _ai_news_lines(
|
||||
[
|
||||
{
|
||||
"title": "仅标题",
|
||||
"link": "https://example.com/b",
|
||||
"source_name": "Src",
|
||||
"published_fmt": "",
|
||||
"desc_short": "",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertIn("[仅标题](https://example.com/b)", lines[0])
|
||||
|
||||
|
||||
class NewsSummaryTests(unittest.TestCase):
|
||||
def test_brief_news_summary_no_ellipsis(self):
|
||||
from daily.news.fetch import brief_news_summary
|
||||
|
||||
text = (
|
||||
"Meta told Dylan Byers, of Puck News, that the company removed "
|
||||
"the controversial AI feature after user backlash on Instagram."
|
||||
)
|
||||
out = brief_news_summary(text, limit=72)
|
||||
self.assertNotIn("...", out)
|
||||
self.assertLessEqual(len(out), 72)
|
||||
self.assertTrue(out.startswith("Meta told"))
|
||||
|
||||
def test_brief_news_summary_filters_junk(self):
|
||||
from daily.news.fetch import brief_news_summary
|
||||
|
||||
self.assertEqual(brief_news_summary("点击查看原文>"), "")
|
||||
self.assertEqual(brief_news_summary("Article URL: https://example.com"), "")
|
||||
|
||||
def test_sync_wecom_news_rows_after_localize(self):
|
||||
from daily.news.fetch import _to_wecom_news_row, sync_wecom_news_rows
|
||||
|
||||
row = _to_wecom_news_row(
|
||||
{
|
||||
"title": "t",
|
||||
"link": "https://a.com/x",
|
||||
"source_name": "s",
|
||||
"published_fmt": "07-11",
|
||||
"summary": "Short english stub that was truncated early...",
|
||||
}
|
||||
)
|
||||
flat = [
|
||||
{
|
||||
"link": "https://a.com/x",
|
||||
"summary": "苹果指控 OpenAI 窃取硬件商业机密,诉讼称 misconduct 涉及多名前员工。",
|
||||
}
|
||||
]
|
||||
sync_wecom_news_rows([row], flat)
|
||||
self.assertNotIn("...", row["desc_short"])
|
||||
self.assertIn("苹果", row["desc_short"])
|
||||
|
||||
|
||||
def test_finalize_wecom_news_forces_chinese(self):
|
||||
from daily.news.fetch import finalize_wecom_news_items
|
||||
|
||||
items = [
|
||||
{
|
||||
"link": "https://a.com/1",
|
||||
"desc_short": "Meta removed the feature after backlash.",
|
||||
"summary_plain": "Meta removed the feature after backlash.",
|
||||
}
|
||||
]
|
||||
with patch(
|
||||
"daily.localize.localize_brief_descriptions",
|
||||
return_value={"wecom-news:https://a.com/1": "Meta 在舆论压力下移除了该功能"},
|
||||
):
|
||||
finalize_wecom_news_items(items, force_chinese=True)
|
||||
self.assertIn("Meta", items[0]["desc_short"])
|
||||
self.assertNotIn("backlash", items[0]["desc_short"])
|
||||
|
||||
|
||||
class NewsPickTests(unittest.TestCase):
|
||||
def test_pick_and_backfill_to_limit(self):
|
||||
from daily.news.fetch import _fill_picked_to_limit, _pick_news_items
|
||||
|
||||
flat = [
|
||||
{"link": f"https://a.com/{i}", "title": f"t{i}", "category_id": "media", "summary": "s"}
|
||||
for i in range(12)
|
||||
]
|
||||
picked = _pick_news_items(flat, 10, ("media",))
|
||||
self.assertEqual(len(picked), 10)
|
||||
picked = _fill_picked_to_limit(picked[:3], [flat], 10)
|
||||
self.assertEqual(len(picked), 10)
|
||||
118
tests/test_scheduler.py
Normal file
118
tests/test_scheduler.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Tests for daily.scheduler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from daily.scheduler import (
|
||||
ClockTime,
|
||||
SchedulerState,
|
||||
next_occurrence_after,
|
||||
parse_hhmm,
|
||||
plan_next_action,
|
||||
)
|
||||
|
||||
|
||||
class ParseHhmmTests(unittest.TestCase):
|
||||
def test_parse(self):
|
||||
t = parse_hhmm("08:50")
|
||||
self.assertEqual((t.hour, t.minute), (8, 50))
|
||||
|
||||
def test_invalid(self):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_hhmm("25:00")
|
||||
|
||||
|
||||
class PlanNextActionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tz = ZoneInfo("Asia/Shanghai")
|
||||
self.gen = ClockTime(8, 50)
|
||||
self.push = ClockTime(9, 0)
|
||||
|
||||
def test_before_generate_waits_for_generate(self):
|
||||
now = datetime(2026, 7, 9, 8, 30, tzinfo=self.tz)
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=SchedulerState(),
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "generate")
|
||||
self.assertEqual(run_at.hour, 8)
|
||||
self.assertEqual(run_at.minute, 50)
|
||||
|
||||
def test_after_generate_before_push_waits_for_push(self):
|
||||
now = datetime(2026, 7, 9, 8, 55, tzinfo=self.tz)
|
||||
state = SchedulerState(last_generate_date="2026-07-09")
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=state,
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "push")
|
||||
self.assertEqual(run_at.hour, 9)
|
||||
|
||||
def test_catch_up_generate_when_started_late(self):
|
||||
now = datetime(2026, 7, 9, 8, 55, tzinfo=self.tz)
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=SchedulerState(),
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "generate")
|
||||
self.assertEqual(run_at, now)
|
||||
|
||||
def test_next_day_after_both_done(self):
|
||||
now = datetime(2026, 7, 9, 10, 0, tzinfo=self.tz)
|
||||
state = SchedulerState(last_generate_date="2026-07-09", last_push_date="2026-07-09")
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=state,
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "generate")
|
||||
self.assertEqual(run_at.date().isoformat(), "2026-07-10")
|
||||
|
||||
def test_evening_start_waits_for_tomorrow_generate(self):
|
||||
now = datetime(2026, 7, 9, 20, 35, tzinfo=self.tz)
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=SchedulerState(),
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "generate")
|
||||
self.assertEqual(run_at.date().isoformat(), "2026-07-10")
|
||||
self.assertEqual((run_at.hour, run_at.minute), (8, 50))
|
||||
|
||||
def test_catch_up_push_when_generate_done(self):
|
||||
now = datetime(2026, 7, 9, 20, 35, tzinfo=self.tz)
|
||||
state = SchedulerState(last_generate_date="2026-07-09")
|
||||
run_at, action = plan_next_action(
|
||||
now=now,
|
||||
tz=self.tz,
|
||||
state=state,
|
||||
generate_at=self.gen,
|
||||
push_at=self.push,
|
||||
)
|
||||
self.assertEqual(action, "push")
|
||||
self.assertEqual(run_at, now)
|
||||
|
||||
|
||||
class NextOccurrenceTests(unittest.TestCase):
|
||||
def test_tomorrow_when_past(self):
|
||||
tz = ZoneInfo("Asia/Shanghai")
|
||||
now = datetime(2026, 7, 9, 10, 0, tzinfo=tz)
|
||||
nxt = next_occurrence_after(ClockTime(8, 50), tz, now)
|
||||
self.assertEqual(nxt.date().isoformat(), "2026-07-10")
|
||||
self.assertEqual((nxt.hour, nxt.minute), (8, 50))
|
||||
@@ -229,6 +229,192 @@ class DeltaFormatTests(unittest.TestCase):
|
||||
self.assertIn("Skills Trending Top 10", text)
|
||||
self.assertNotIn("Skills Trending 变化", text)
|
||||
|
||||
def test_delta_pad_keeps_large_clusters_merged_and_fills_limit(self):
|
||||
"""补榜按 source 合并态取条,大 cluster 不得撑爆 flat 预算导致短榜。"""
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
def cluster(source: str, n: int, installs: int) -> dict:
|
||||
titles = [f"t{i}" for i in range(n)]
|
||||
return {
|
||||
"id": f"{source}/{titles[0]}",
|
||||
"title": titles[0],
|
||||
"source": source,
|
||||
"installs": installs,
|
||||
"installs_fmt": str(installs),
|
||||
"cluster": True,
|
||||
"cluster_count": n,
|
||||
"cluster_skills": titles,
|
||||
"cluster_titles": ", ".join(titles[:4]) + "…",
|
||||
"link": f"https://skills.sh/{source}/{titles[0]}",
|
||||
"description": f"{source} cluster",
|
||||
}
|
||||
|
||||
full = [cluster(f"big{i}/pkg", 20, 1000 - i) for i in range(1, 5)] + [
|
||||
{
|
||||
"id": f"other{n}/pkg/skill",
|
||||
"title": "skill",
|
||||
"source": f"other{n}/pkg",
|
||||
"installs": 50 - n,
|
||||
"installs_fmt": str(50 - n),
|
||||
"link": f"https://skills.sh/other{n}/pkg/skill",
|
||||
"description": f"other {n}",
|
||||
}
|
||||
for n in range(1, 12)
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
text = build_skills_delta_sections(
|
||||
[],
|
||||
[],
|
||||
trending_full=full,
|
||||
hot_full=[],
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
)
|
||||
self.assertIn("Skills Trending Top 10", text)
|
||||
self.assertIn("20 skills", text)
|
||||
self.assertIn("other6/pkg", text)
|
||||
|
||||
def test_delta_pad_recent_blocks_same_source_not_just_primary_id(self):
|
||||
"""周去重按 source:换同仓另一个 skill id 不得再上榜。"""
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
titles = [f"t{i}" for i in range(20)]
|
||||
full = [
|
||||
{
|
||||
"id": f"big/pkg/{titles[0]}",
|
||||
"title": titles[0],
|
||||
"source": "big/pkg",
|
||||
"installs": 999,
|
||||
"installs_fmt": "999",
|
||||
"cluster": True,
|
||||
"cluster_count": 20,
|
||||
"cluster_skills": titles,
|
||||
"cluster_titles": ", ".join(titles[:4]) + "…",
|
||||
"link": f"https://skills.sh/big/pkg/{titles[0]}",
|
||||
"description": "big cluster",
|
||||
},
|
||||
*[
|
||||
{
|
||||
"id": f"other{n}/pkg/skill",
|
||||
"title": "skill",
|
||||
"source": f"other{n}/pkg",
|
||||
"installs": 50 - n,
|
||||
"installs_fmt": str(50 - n),
|
||||
"link": f"https://skills.sh/other{n}/pkg/skill",
|
||||
"description": f"other {n}",
|
||||
}
|
||||
for n in range(1, 12)
|
||||
],
|
||||
]
|
||||
# 昨日展示的是同 source 另一 skill id(非今日 primary)
|
||||
recent = {f"big/pkg/{titles[5]}"}
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
text = build_skills_delta_sections(
|
||||
[],
|
||||
[],
|
||||
trending_full=full,
|
||||
hot_full=[],
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
recent_trending=recent,
|
||||
)
|
||||
self.assertIn("Skills Trending Top 10", text)
|
||||
self.assertNotIn("big/pkg", text)
|
||||
self.assertIn("other1/pkg", text)
|
||||
|
||||
def test_delta_pad_hot_recent_unions_trending_history_by_source(self):
|
||||
"""Skills Hot 周去重合并 Trending 历史:隔日换榜也不能同 source 再出现。"""
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
hot_full = [
|
||||
{
|
||||
"id": "101-skills/skills/ai-music",
|
||||
"title": "ai-music",
|
||||
"source": "101-skills/skills",
|
||||
"installs": 200,
|
||||
"installs_fmt": "200",
|
||||
"link": "https://skills.sh/101-skills/skills/ai-music",
|
||||
"description": "hot candidate",
|
||||
},
|
||||
{
|
||||
"id": "fresh/src/skill",
|
||||
"title": "skill",
|
||||
"source": "fresh/src",
|
||||
"installs": 100,
|
||||
"installs_fmt": "100",
|
||||
"link": "https://skills.sh/fresh/src/skill",
|
||||
"description": "fresh",
|
||||
},
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
text = build_skills_delta_sections(
|
||||
[],
|
||||
[],
|
||||
trending_full=[],
|
||||
hot_full=hot_full,
|
||||
trending_limit=10,
|
||||
hot_limit=10,
|
||||
pad=True,
|
||||
recent_trending={"101-skills/skills/ai-video-generation"},
|
||||
recent_hot=set(),
|
||||
)
|
||||
self.assertIn("Skills Hot Top 1", text)
|
||||
self.assertIn("fresh/src", text)
|
||||
self.assertNotIn("101-skills", text)
|
||||
|
||||
def test_delta_pad_hot_excludes_trending_by_source(self):
|
||||
"""同日 Hot 补榜按 source 避开 Trending,而非展开全部 cluster skill id。"""
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
trending_full = [
|
||||
{
|
||||
"id": "same/src/a",
|
||||
"title": "a",
|
||||
"source": "same/src",
|
||||
"installs": 100,
|
||||
"installs_fmt": "100",
|
||||
"link": "https://skills.sh/same/src/a",
|
||||
"description": "trending item",
|
||||
}
|
||||
]
|
||||
hot_full = [
|
||||
{
|
||||
"id": "same/src/b",
|
||||
"title": "b",
|
||||
"source": "same/src",
|
||||
"installs": 90,
|
||||
"installs_fmt": "90",
|
||||
"link": "https://skills.sh/same/src/b",
|
||||
"description": "hot twin",
|
||||
},
|
||||
{
|
||||
"id": "fresh/src/skill",
|
||||
"title": "skill",
|
||||
"source": "fresh/src",
|
||||
"installs": 80,
|
||||
"installs_fmt": "80",
|
||||
"link": "https://skills.sh/fresh/src/skill",
|
||||
"description": "fresh hot",
|
||||
},
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
text = build_skills_delta_sections(
|
||||
[],
|
||||
[],
|
||||
trending_full=trending_full,
|
||||
hot_full=hot_full,
|
||||
trending_limit=10,
|
||||
hot_limit=10,
|
||||
pad=True,
|
||||
)
|
||||
self.assertIn("Skills Hot Top 1", text)
|
||||
self.assertIn("fresh/src", text)
|
||||
self.assertNotIn("same/src/b", text)
|
||||
|
||||
def test_delta_pad_uses_large_pool_when_recent_excludes_top(self):
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
@@ -306,6 +492,46 @@ class DeltaFormatTests(unittest.TestCase):
|
||||
self.assertIn("GitHub Trending Top 10", text)
|
||||
self.assertNotIn("GitHub Trending 变化", text)
|
||||
|
||||
def test_delta_pad_github_unions_recent_across_boards(self):
|
||||
"""GitHub 三榜共用周去重:Trending 出过的 repo,新兴/Topic 不得再出。"""
|
||||
from daily.format_wecom import build_github_delta_sections
|
||||
|
||||
movement = {"github_trending_moves": [], "github_emerging_moves": [], "github_topic_moves": []}
|
||||
shared = {
|
||||
"repo": "seen/repo",
|
||||
"url": "https://github.com/seen/repo",
|
||||
"language": "Go",
|
||||
"stars_today_fmt": "100",
|
||||
"total_stars_fmt": "1K",
|
||||
"created_at": "2026-07-01",
|
||||
"description": "already shown",
|
||||
"desc_short": "already shown",
|
||||
}
|
||||
fresh = {
|
||||
"repo": "fresh/repo",
|
||||
"url": "https://github.com/fresh/repo",
|
||||
"language": "Go",
|
||||
"stars_today_fmt": "90",
|
||||
"total_stars_fmt": "900",
|
||||
"created_at": "2026-07-02",
|
||||
"description": "fresh",
|
||||
"desc_short": "fresh",
|
||||
}
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
text = build_github_delta_sections(
|
||||
movement,
|
||||
topic_name="llm",
|
||||
github_trending=[],
|
||||
github_emerging=[shared, fresh],
|
||||
github_topic=[shared],
|
||||
emerging_limit=5,
|
||||
topic_limit=5,
|
||||
pad=True,
|
||||
recent_board_keys={"github_trending": {"seen/repo"}},
|
||||
)
|
||||
self.assertIn("fresh/repo", text)
|
||||
self.assertNotIn("seen/repo", text)
|
||||
|
||||
def test_delta_pad_skips_recent_skills(self):
|
||||
from daily.format_wecom import build_skills_delta_sections
|
||||
|
||||
@@ -320,6 +546,16 @@ class DeltaFormatTests(unittest.TestCase):
|
||||
"description": f"skill {i}",
|
||||
}
|
||||
for i in range(4, 6)
|
||||
] + [
|
||||
{
|
||||
"id": "fresh/src/skill",
|
||||
"title": "skill",
|
||||
"source": "fresh/src",
|
||||
"installs": 50,
|
||||
"installs_fmt": "50",
|
||||
"link": "https://skills.sh/fresh/src/skill",
|
||||
"description": "fresh skill",
|
||||
}
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
with patch("daily.format_wecom.needs_chinese", return_value=False):
|
||||
@@ -330,10 +566,12 @@ class DeltaFormatTests(unittest.TestCase):
|
||||
hot_full=[],
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
# 同仓历史 skill id → 整仓 source 去重;仅保留其它 source
|
||||
recent_trending={f"x/y/s{i}" for i in range(1, 4)},
|
||||
)
|
||||
self.assertIn("Skills Trending Top 1", text)
|
||||
self.assertIn("2 skills", text)
|
||||
self.assertIn("fresh/src", text)
|
||||
self.assertNotIn("x/y", text)
|
||||
|
||||
def test_delta_pad_skips_recent_github(self):
|
||||
from daily.format_wecom import build_github_delta_sections
|
||||
@@ -363,6 +601,45 @@ class DeltaFormatTests(unittest.TestCase):
|
||||
self.assertIn("org/r4", text)
|
||||
self.assertNotIn("org/r1", text)
|
||||
|
||||
def test_delta_pad_skips_recent_github_moves(self):
|
||||
"""异动新入榜若昨日企微已展示,pad 时仍应排除(不只滤补榜)。"""
|
||||
from daily.format_wecom import build_github_delta_sections
|
||||
|
||||
movement = {
|
||||
"github_trending_moves": [
|
||||
{
|
||||
"repo": "vinta/awesome-python",
|
||||
"url": "https://github.com/vinta/awesome-python",
|
||||
"language": "Python",
|
||||
"total_stars_fmt": "308K",
|
||||
"description": "list",
|
||||
}
|
||||
],
|
||||
"github_emerging_moves": [],
|
||||
"github_topic_moves": [],
|
||||
}
|
||||
full = [
|
||||
{
|
||||
"repo": "fresh/repo",
|
||||
"url": "https://github.com/fresh/repo",
|
||||
"language": "Go",
|
||||
"total_stars_fmt": "1K",
|
||||
"description": "fresh",
|
||||
"desc_short": "fresh",
|
||||
}
|
||||
]
|
||||
with patch("daily.format_wecom.localize_brief_descriptions", return_value={}):
|
||||
text = build_github_delta_sections(
|
||||
movement,
|
||||
topic_name="llm",
|
||||
github_trending=full,
|
||||
trending_limit=10,
|
||||
pad=True,
|
||||
recent_board_keys={"github_trending": {"vinta/awesome-python"}},
|
||||
)
|
||||
self.assertIn("fresh/repo", text)
|
||||
self.assertNotIn("vinta/awesome-python", text)
|
||||
|
||||
def test_load_recent_board_keys_from_data_json(self):
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
Reference in New Issue
Block a user