-
-
Notifications
You must be signed in to change notification settings - Fork 277
Expand file tree
/
Copy pathtest_update_changelog.py
More file actions
179 lines (134 loc) · 5.79 KB
/
Copy pathtest_update_changelog.py
File metadata and controls
179 lines (134 loc) · 5.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
#!/usr/bin/env python3
"""Self-contained tests for update_changelog.py.
Runs without network or the ``google-genai`` package (the LLM import in the
script is lazy). Exercises the deterministic pieces that keep the changelog
safe: section splitting, duplicate detection, prefix handling, the fallback
classifier, and the validator that guards LLM output.
Run locally with either:
python .github/scripts/test_update_changelog.py
pytest .github/scripts/test_update_changelog.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import update_changelog as uc # noqa: E402
SAMPLE = """\
# RocketPy Change Log
## [Unreleased] - yyyy-mm-dd
### Added
### Changed
### Fixed
## [v1.13.0] - 2026-07-21
### Added
- ENH: something old [#100](https://github.com/RocketPy-Team/RocketPy/pull/100)
"""
def test_split_roundtrips():
before, block, after = uc.split_changelog(SAMPLE)
assert before + block + after == SAMPLE
assert block.startswith("## [Unreleased]")
assert "## [v1.13.0]" not in block
assert "## [v1.13.0]" in after
def test_already_present():
_, block, after = uc.split_changelog(SAMPLE)
assert uc.already_present(after, 100) is True
assert uc.already_present(block, 100) is False
assert uc.already_present(block, 999) is False
def test_detect_prefix():
assert uc.detect_prefix("BUG: fix a thing") == "BUG"
assert uc.detect_prefix("BUG/MNT: pre-release review fixes") == "BUG/MNT"
assert uc.detect_prefix("Add a shiny feature") is None
def test_no_double_prefix():
# The historical bug: a title already carrying a prefix must not gain another.
entry = uc.build_entry("BUG/MNT: pre-release fixes", 1074, "ENH", "BUG/MNT")
assert entry.startswith("- BUG/MNT: pre-release fixes ")
assert "ENH:" not in entry
assert "[#1074](https://github.com/RocketPy-Team/RocketPy/pull/1074)" in entry
def test_build_entry_adds_prefix_when_missing():
entry = uc.build_entry("Add a shiny feature", 200, "ENH", None)
assert entry.startswith("- ENH: Add a shiny feature ")
def test_fallback_routing():
# Bug label -> Fixed
section, prefix, _ = uc.fallback_section_and_prefix("Fix crash", "Bug,Flight")
assert section == "### Fixed" and prefix == "BUG"
# Refactor label -> Changed
section, _, _ = uc.fallback_section_and_prefix("Tidy internals", "Refactor")
assert section == "### Changed"
# Existing prefix wins the routing even without a matching label
section, prefix, existing = uc.fallback_section_and_prefix("BUG: oops", "")
assert section == "### Fixed" and existing == "BUG"
# Default
section, prefix, _ = uc.fallback_section_and_prefix("New capability", "Enhancement")
assert section == "### Added" and prefix == "ENH"
def test_fallback_update_inserts_once_under_right_section():
_, block, _ = uc.split_changelog(SAMPLE)
updated = uc.fallback_update(block, "BUG: fix a thing", 321, "Bug")
assert updated.count("[#321]") == 1
fixed = updated.split("### Fixed", 1)[1]
assert "- BUG: fix a thing" in fixed
# Not misplaced under Added.
added = updated.split("### Added", 1)[1].split("### Changed", 1)[0]
assert "[#321]" not in added
def test_ensure_section_inserts_in_canonical_order():
_, block, _ = uc.split_changelog(SAMPLE)
# SAMPLE has Added/Changed/Fixed but no Removed subsection.
lines = uc.ensure_section(block.splitlines(keepends=True), "### Removed")
joined = "".join(lines)
# Removed must sit after Changed and before Fixed.
assert (
joined.index("### Changed")
< joined.index("### Removed")
< joined.index("### Fixed")
)
def test_validator_accepts_good_output():
_, block, _ = uc.split_changelog(SAMPLE)
good = block.replace(
"### Fixed\n",
"### Fixed\n\n- BUG: fix it [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n",
)
assert uc.validate_llm_block(block, good, 500) is None
def test_validator_rejects_dropped_entry():
old = block_with_entry()
# New block loses the pre-existing entry.
new = "## [Unreleased] - yyyy-mm-dd\n\n### Fixed\n\n- BUG: new [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n\n"
assert uc.validate_llm_block(old, new, 500) is not None
def test_validator_rejects_missing_new_ref():
_, block, _ = uc.split_changelog(SAMPLE)
assert uc.validate_llm_block(block, block, 500) is not None # no new ref at all
def test_validator_rejects_leaked_version_header():
_, block, _ = uc.split_changelog(SAMPLE)
leaked = (
"## [Unreleased] - yyyy-mm-dd\n\n### Fixed\n\n"
"- BUG: x [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n\n"
"## [v1.13.0] - 2026-07-21\n"
)
assert uc.validate_llm_block(block, leaked, 500) is not None
def test_validator_rejects_runaway_growth():
_, block, _ = uc.split_changelog(SAMPLE)
huge = (
"## [Unreleased] - yyyy-mm-dd\n\n### Fixed\n\n"
"- BUG: x [#500](https://github.com/RocketPy-Team/RocketPy/pull/500)\n"
+ ("x" * (uc.MAX_BLOCK_GROWTH + 50))
)
assert uc.validate_llm_block(block, huge, 500) is not None
def block_with_entry():
return (
"## [Unreleased] - yyyy-mm-dd\n\n### Added\n\n"
"- ENH: keep me [#200](https://github.com/RocketPy-Team/RocketPy/pull/200)\n\n"
"### Fixed\n\n"
)
def _run_all():
tests = [
v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)
]
failures = 0
for test in tests:
try:
test()
print(f" ok {test.__name__}")
except AssertionError as exc:
failures += 1
print(f" FAIL {test.__name__}: {exc}")
print(f"\n{len(tests) - failures}/{len(tests)} passed")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(_run_all())