tests/test_changelog.py
Rama master · réplica de lectura del repositorio original.
import contextlib
import io
import re
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from scripts import changelog
from scripts.changelog import Commit, entry_for, group_entries, has_section, promote, render_body
from scripts.version_bump import VersionError
REPO = "owner/repo"
#: 一份最小的 `CHANGELOG.md`:头部、润色过的未发布节、一个发布节、底部链接块。
DOCUMENT = """# 变更日志
说明段。
## [未发布]
### 新增
- 配置页新增桌面快捷方式开关。
### 修复
- 忙态提示会说明正在进行的动作。
## [0.27.1] - 2026-09-06
第四个预发布。
[未发布]: https://github.com/owner/repo/compare/v0.27.1...HEAD
[0.27.1]: https://github.com/owner/repo/releases/tag/v0.27.1
"""
class EntryTests(unittest.TestCase):
def test_only_user_facing_types_become_entries(self):
self.assertEqual(entry_for(Commit("feat: 配置页并入管理菜单", "")),
("新增", "配置页并入管理菜单"))
self.assertEqual(entry_for(Commit("fix(web): 忙态提示说明在做什么", "")),
("修复", "web:忙态提示说明在做什么"))
self.assertEqual(entry_for(Commit("perf(media): 首段缩到 0.67 秒", "")),
("变更", "**播放**:首段缩到 0.67 秒"))
for subject in ("docs: 记一笔", "test: 补断言", "chore(release): 版本 0.29.1",
"refactor(follow): 登记表收口", "style: 空行", "随手写的主题"):
with self.subTest(subject=subject):
self.assertIsNone(entry_for(Commit(subject, "")))
def test_known_scopes_become_area_labels_and_unknown_ones_wait_for_a_human(self):
self.assertEqual(entry_for(Commit("feat(review): 候选支持多选", "")),
("新增", "**复核**:候选支持多选"))
self.assertEqual(entry_for(Commit("fix(avatars): 只装更大的那张", "")),
("修复", "**采集**:只装更大的那张"))
self.assertEqual(entry_for(Commit("fix(web): 忙态提示说明在做什么", "")),
("修复", "web:忙态提示说明在做什么"))
self.assertEqual(entry_for(Commit("feat: 没有 scope 就没有标签", "")),
("新增", "没有 scope 就没有标签"))
def test_every_mapped_label_is_in_the_closed_vocabulary(self):
self.assertLessEqual(set(changelog.AREA_BY_SCOPE.values()),
set(changelog.AREAS))
def test_development_only_scopes_stay_out_even_under_fix(self):
self.assertIsNone(entry_for(Commit("fix(tests): 断言跟上新名字", "")))
self.assertIsNone(entry_for(Commit("fix(docs): 链接失效", "")))
def test_breaking_changes_are_told_by_the_bang_or_the_footer(self):
self.assertEqual(entry_for(Commit("fix!: 数据库路径改成 location 键", "")),
("破坏性变化", "数据库路径改成 location 键"))
self.assertEqual(
entry_for(Commit("feat(config): 换掉盘符键", "BREAKING CHANGE: 设置文件要重写")),
("破坏性变化", "**配置**:设置文件要重写"))
def test_security_work_gets_its_own_group(self):
self.assertEqual(entry_for(Commit("fix(security): 凭据不再进日志", "")),
("安全", "凭据不再进日志"))
def test_groups_keep_the_documented_order_and_drop_duplicates(self):
grouped = group_entries([
Commit("fix: 同一句", ""), Commit("feat: 新东西", ""),
Commit("fix: 同一句", ""), Commit("fix!: 破坏性的", ""),
])
self.assertEqual(list(grouped), ["破坏性变化", "新增", "修复"])
self.assertEqual(grouped["修复"], ["同一句"])
def test_the_body_is_markdown_with_one_blank_line_between_groups(self):
body = render_body(group_entries([Commit("feat: 甲", ""), Commit("fix: 乙", "")]))
self.assertEqual(body, "### 新增\n\n- 甲\n\n### 修复\n\n- 乙")
class PromoteTests(unittest.TestCase):
def promoted(self, document: str = DOCUMENT, **overrides) -> str:
options = {"date": "2026-09-07", "repo": REPO, "previous": "v0.27.1"}
return promote(document, overrides.pop("version", "0.28.0"),
**{**options, **overrides})
def test_the_unreleased_wording_moves_into_the_new_section(self):
updated = self.promoted()
self.assertIn("## [0.28.0] - 2026-09-07", updated)
self.assertIn("- 配置页新增桌面快捷方式开关。", updated.split("## [0.28.0]")[1])
self.assertTrue(has_section(updated, "0.28.0"))
def test_an_empty_unreleased_section_is_left_behind_at_the_top(self):
updated = self.promoted()
between = updated.split(f"## [{changelog.UNRELEASED}]")[1].split("## [0.28.0]")[0]
self.assertEqual(between.strip(), "")
def test_the_sections_stay_in_descending_order(self):
updated = self.promoted()
self.assertLess(updated.index("## [0.28.0]"), updated.index("## [0.27.1]"))
def test_both_compare_links_are_written(self):
updated = self.promoted()
self.assertIn("[未发布]: https://github.com/owner/repo/compare/v0.28.0...HEAD", updated)
self.assertIn("[0.28.0]: https://github.com/owner/repo/compare/v0.27.1...v0.28.0",
updated)
self.assertIn("[0.27.1]: https://github.com/owner/repo/releases/tag/v0.27.1", updated)
def test_the_first_release_links_to_its_own_tag(self):
document = DOCUMENT.replace(
"## [0.27.1] - 2026-09-06\n\n第四个预发布。\n\n", "").replace(
"[0.27.1]: https://github.com/owner/repo/releases/tag/v0.27.1\n", "").replace(
"compare/v0.27.1...HEAD", "compare/v0.1.0...HEAD")
updated = self.promoted(document, version="0.1.0", previous=None)
self.assertIn("[0.1.0]: https://github.com/owner/repo/releases/tag/v0.1.0", updated)
def test_a_draft_fills_in_when_nobody_wrote_the_unreleased_section(self):
bare = DOCUMENT.replace(
"### 新增\n\n- 配置页新增桌面快捷方式开关。\n\n"
"### 修复\n\n- 忙态提示会说明正在进行的动作。\n\n", "")
updated = promote(bare, "0.28.0", date="2026-09-07", repo=REPO, previous="v0.27.1",
fallback="### 新增\n\n- 起草出来的那条")
self.assertIn("- 起草出来的那条", updated)
def test_a_release_with_nothing_to_say_is_refused(self):
bare = DOCUMENT.replace(
"### 新增\n\n- 配置页新增桌面快捷方式开关。\n\n"
"### 修复\n\n- 忙态提示会说明正在进行的动作。\n\n", "")
with self.assertRaisesRegex(VersionError, "没有可记录的变化"):
promote(bare, "0.28.0", date="2026-09-07", repo=REPO, previous="v0.27.1")
def test_the_same_version_cannot_be_promoted_twice(self):
with self.assertRaisesRegex(VersionError, "已经有 0.27.1"):
self.promoted(version="0.27.1")
def test_a_document_without_the_required_anchors_is_refused(self):
with self.assertRaisesRegex(VersionError, "未发布"):
self.promoted("# 变更日志\n\n## [0.27.1] - 2026-09-06\n")
without_link = DOCUMENT.replace(
"[未发布]: https://github.com/owner/repo/compare/v0.27.1...HEAD\n", "")
with self.assertRaisesRegex(VersionError, "链接"):
self.promoted(without_link)
class SectionTests(unittest.TestCase):
"""取出一节的正文:发布前要人确认的就是这段文字。"""
def test_a_section_stops_at_the_next_heading(self):
self.assertEqual(changelog.section_of(DOCUMENT, changelog.UNRELEASED),
"### 新增\n\n- 配置页新增桌面快捷方式开关。\n"
"\n### 修复\n\n- 忙态提示会说明正在进行的动作。")
def test_the_last_section_stops_before_the_link_block(self):
self.assertEqual(changelog.section_of(DOCUMENT, "0.27.1"), "第四个预发布。")
def test_a_version_without_a_section_yields_nothing(self):
self.assertEqual(changelog.section_of(DOCUMENT, "0.28.0"), "")
def test_a_release_refuses_raw_commit_scopes_before_it_is_shipped(self):
raw = "## [0.8.0] - 2026-09-19\n\n### 修复\n\n- web:修好一处界面\n"
with self.assertRaisesRegex(VersionError, "未归类.*web"):
changelog.require_labeled_entries(raw, "0.8.0")
labeled = raw.replace("web:", "**界面**:")
changelog.require_labeled_entries(labeled, "0.8.0")
def test_a_release_refuses_an_entry_longer_than_one_sentence(self):
"""条目给使用者扫读:标签之后超过上限就拒绝定版,正好到上限的照常通过。"""
head = "## [0.8.0] - 2026-09-19\n\n### 修复\n\n- **界面**:"
changelog.require_labeled_entries(head + "字" * changelog.ENTRY_LIMIT + "\n", "0.8.0")
with self.assertRaisesRegex(VersionError, f"超过 {changelog.ENTRY_LIMIT} 字"):
changelog.require_labeled_entries(head + "字" * (changelog.ENTRY_LIMIT + 1) + "\n", "0.8.0")
# 链接地址读者看不到,只计显示的文字。
linked = "字" * (changelog.ENTRY_LIMIT - 2) + "[说明](docs/DOCKER.md#一键更新可选-watchtower)"
changelog.require_labeled_entries(head + linked + "\n", "0.8.0")
def test_an_overlong_entry_in_an_older_release_also_blocks_the_release(self):
"""旧版本改写时也守住上限:任一已发布版本有超长条目,新版本就不能定版;旧版本没有标签也照样按字数算。"""
current = "## [0.8.0] - 2026-09-19\n\n### 修复\n\n- **界面**:修好一处界面。\n\n"
older = "## [0.7.0] - 2026-09-01\n\n### 修复\n\n- " + "字" * (changelog.ENTRY_LIMIT + 1) + "\n"
with self.assertRaisesRegex(VersionError, "0.7.0:"):
changelog.require_labeled_entries(current + older, "0.8.0")
changelog.require_labeled_entries(current + older.replace("字", "", 1), "0.8.0")
class NotesTests(unittest.TestCase):
"""Release 页正文:那一节原文、到上一版的完整变更链接、安装提示;缺那一节或链接就拒绝。"""
def test_the_notes_are_the_section_its_full_changelog_and_the_install_note(self):
self.assertEqual(changelog.notes(DOCUMENT, "0.27.1"),
"第四个预发布。\n\n[Full Changelog / 完整变更]"
"(https://github.com/owner/repo/releases/tag/v0.27.1)\n\n"
f"{changelog.INSTALL_NOTE}\n")
def test_a_version_without_a_section_cannot_be_released(self):
with self.assertRaisesRegex(VersionError, "0.28.0"):
changelog.notes(DOCUMENT, "0.28.0")
def test_a_section_without_its_link_cannot_be_released(self):
unlinked = DOCUMENT.replace("[0.27.1]: https://github.com/owner/repo/releases/tag/v0.27.1\n", "")
with self.assertRaisesRegex(VersionError, r"\[0\.27\.1\]:"):
changelog.notes(unlinked, "0.27.1")
def test_the_command_line_names_what_it_failed_to_produce(self):
"""`--notes` 失败说的是 Release 正文,不借用定版那条「变更日志未更新」。"""
with tempfile.TemporaryDirectory() as folder:
(Path(folder) / changelog.CHANGELOG).write_text(DOCUMENT, encoding="utf-8")
out = io.StringIO()
with mock.patch.object(changelog, "ROOT", Path(folder)), \
contextlib.redirect_stdout(out):
code = changelog.main(["--notes", "0.28.0"])
self.assertEqual(code, 1)
self.assertTrue(out.getvalue().startswith("Release 正文未生成:"), out.getvalue())
self.assertNotIn("变更日志未更新", out.getvalue())
def test_the_release_workflow_reads_the_notes_it_does_not_inline_them(self):
"""固定文案只活在 `INSTALL_NOTE` 一处;工作流在标签提交上生成文件再交给 gh。"""
workflow = (Path(changelog.ROOT) / ".github/workflows/release.yml").read_text(encoding="utf-8")
self.assertIn("scripts/changelog.py --notes", workflow)
self.assertIn("--to release/RELEASE_NOTES.md", workflow)
self.assertIn("--notes-file RELEASE_NOTES.md", workflow)
self.assertNotIn("--notes 'Windows", workflow)
class RecordedMergeTests(unittest.TestCase):
"""`merge -s ours` 只为署名记入社区提交,那些改动不在主线上,不进日志也不抬版本级别。"""
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.root = Path(temp.name).resolve()
self.git("init", "-q", "-b", "master")
self.git("config", "user.email", "test@example.invalid")
self.git("config", "user.name", "Test")
self.commit("chore: 起点")
self.git("tag", "v0.1.0")
def git(self, *args):
return subprocess.run(["git", *args], cwd=self.root, check=True, capture_output=True,
text=True, encoding="utf-8").stdout
def commit(self, subject):
name = f"{len(list(self.root.glob('*.txt')))}.txt"
(self.root / name).write_text(subject, encoding="utf-8")
self.git("add", "--", name)
self.git("commit", "-q", "-m", subject)
def test_only_commits_whose_changes_reached_master_are_drafted(self):
self.git("checkout", "-q", "-b", "community")
self.commit("feat(web): 对方的整套译文")
self.git("checkout", "-q", "master")
self.git("merge", "-q", "-s", "ours", "--no-ff", "-m", "chore(i18n): 记入社区译法", "community")
self.git("checkout", "-q", "-b", "worker")
self.commit("fix(web): 正常合入的修复")
self.git("checkout", "-q", "master")
self.git("merge", "-q", "--no-ff", "-m", "Merge branch 'worker'", "worker")
self.commit("fix(web): 主线上的修复")
expected = ["fix(web): 主线上的修复", "fix(web): 正常合入的修复"]
self.assertEqual([c.subject for c in changelog.read_commits(self.root, "v0.1.0..HEAD")], expected)
self.assertEqual(changelog.version_bump.subjects_in(self.root, "v0.1.0..HEAD"), expected)
def test_master_commits_the_recorded_branch_merged_in_still_count(self):
self.commit("fix(web): 标签之后的主线修复")
self.git("checkout", "-q", "-b", "community")
self.git("merge", "-q", "--no-ff", "-m", "Merge master into community", "master")
self.commit("feat(web): 对方的整套译文")
self.git("checkout", "-q", "master")
self.git("merge", "-q", "-s", "ours", "--no-ff", "-m", "chore(i18n): 记入社区译法", "community")
self.assertEqual([c.subject for c in changelog.read_commits(self.root, "v0.1.0..HEAD")],
["fix(web): 标签之后的主线修复"])
class DueTests(unittest.TestCase):
"""该不该发下一版。判据只看使用者那一侧,不看集成了多少次。"""
def _due(self, subjects, *, days):
commits = [Commit(subject, "") for subject in subjects]
with mock.patch.object(changelog, "read_commits", return_value=commits), \
mock.patch.object(changelog, "_waiting_days", return_value=days), \
mock.patch.object(changelog.version_bump, "release_range",
return_value="v0.1.0..HEAD"):
return changelog.due(Path("."))
def test_a_hundred_refactors_are_zero_changes_to_a_user(self):
"""这条判据要跟「集成了多少次」分开,不然又变成按工作量发版。"""
found = self._due([f"refactor(web): 第 {n} 次收口" for n in range(100)], days=30)
self.assertEqual((found["entries"], found["due"], found["why"]), (0, False, []))
def test_the_weekly_slot_needs_something_to_put_in_it(self):
subjects = ["fix(web): 修一处"]
self.assertFalse(self._due(subjects, days=changelog.DUE_DAYS - 1)["due"])
ripe = self._due(subjects, days=changelog.DUE_DAYS)
self.assertTrue(ripe["due"])
self.assertIn(f"距上一版 {changelog.DUE_DAYS} 天", ripe["why"][0])
def test_enough_entries_do_not_wait_for_the_slot(self):
subjects = [f"feat(web): 第 {n} 件事" for n in range(changelog.DUE_ENTRIES)]
found = self._due(subjects, days=0)
self.assertTrue(found["due"])
self.assertIn("不必等满周期", found["why"][0])
def test_breaking_changes_and_security_fixes_never_wait(self):
for subject, group in (("feat(config)!: 换掉盘符键", changelog.BREAKING),
("fix(security): 凭据不再进日志", changelog.SECURITY_GROUP)):
with self.subTest(group=group):
found = self._due([subject], days=0)
self.assertTrue(found["due"])
self.assertIn(group, found["why"][-1])
self.assertIn("不等周期", found["why"][-1])
def test_the_counts_it_reports_are_what_calibrates_the_threshold(self):
"""阈值是没有样本时的起点,所以每次都要把实际值报出来。"""
found = self._due(["feat: 甲", "fix: 乙", "fix: 丙"], days=2)
self.assertEqual((found["entries"], found["days"]), (3, 2))
self.assertEqual(found["groups"], {"新增": 1, "修复": 2})
class ShippedChangelogTests(unittest.TestCase):
"""仓库里那份 `CHANGELOG.md` 自己要符合脚本认的形状。"""
def setUp(self):
self.document = (Path(changelog.ROOT) / changelog.CHANGELOG).read_text(encoding="utf-8")
def test_the_anchors_the_release_entry_needs_are_all_present(self):
self.assertRegex(self.document, r"(?m)^## \[未发布\]$")
self.assertRegex(self.document, r"(?m)^\[未发布\]: https://github\.com/")
self.assertIn("keepachangelog.com", self.document)
self.assertIn("semver.org", self.document)
def test_every_released_section_carries_an_iso_date(self):
headings = [line for line in self.document.splitlines()
if line.startswith("## [") and not line.startswith("## [未发布]")]
self.assertTrue(headings, "至少要留一节已发布版本,compare 链条才接得上")
for heading in headings:
with self.subTest(heading=heading):
self.assertRegex(heading, r"^## \[\d+\.\d+\.\d+\] - \d{4}-\d{2}-\d{2}$")
def _released_versions(self):
return re.findall(r"(?m)^## \[(\d+\.\d+\.\d+)\]", self.document)
def test_every_released_section_has_its_compare_link(self):
for version in self._released_versions():
with self.subTest(version=version):
self.assertRegex(self.document,
rf"(?m)^\[{re.escape(version)}\]: https://github\.com/")
def test_every_entry_carries_a_label_from_the_closed_vocabulary(self):
allowed = "|".join(changelog.AREAS)
for line in self.document.splitlines():
if not line.startswith("- "):
continue
with self.subTest(line=line):
self.assertRegex(line, rf"^- \*\*(?:{allowed})\*\*:")
def test_released_sections_run_newest_first(self):
keys = [tuple(int(part) for part in version.split("."))
for version in self._released_versions()]
self.assertEqual(keys, sorted(keys, reverse=True))
if __name__ == "__main__":
unittest.main()