Ctrl K
ComunidadGuías…

tests/test_changelog.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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()