from __future__ import annotations
import re
import pytest
from changelog_utils import _ReleaseNotesPostProcessor
def _section_body(lines: list[str], header: str) -> list[str]:
try:
start = next(i for i, line in enumerate(lines) if line.strip() == header)
except StopIteration as exc: pytest.fail(f"Section header not found: {header!r}")
raise AssertionError from exc
body: list[str] = []
i = start + 1
while i < len(lines) and not lines[i].startswith("### ") and not lines[i].startswith("## "):
body.append(lines[i])
i += 1
return body
class TestReleaseNotesPostProcessor:
def test_breaking_changes_moved_out_of_changed_section(self):
content = """# Changelog
## [v1.0.0] - 2025-01-01
### Changed
- **Bump MSRV to 1.92.0** [`abcdef123`](https://github.com/acgetchell/delaunay/commit/abcdef123)
Details that should not appear in the breaking-changes summary.
- **Regular change** [`123456789`](https://github.com/acgetchell/delaunay/commit/123456789)
### Added
- **Some new feature** [`999999999`](https://github.com/acgetchell/delaunay/commit/999999999)
"""
processed = _ReleaseNotesPostProcessor.process(content)
lines = processed.splitlines()
assert "### ⚠️ Breaking Changes" in processed, "Expected breaking changes section to be present"
breaking_start = lines.index("### ⚠️ Breaking Changes")
changed_start = lines.index("### Changed")
assert breaking_start < changed_start, "Expected breaking changes section to appear before the Changed section"
breaking_body = _section_body(lines, "### ⚠️ Breaking Changes")
assert any("MSRV" in line for line in breaking_body), "Expected MSRV entry to be present in breaking changes section"
changed_body = _section_body(lines, "### Changed")
assert not any("MSRV" in line for line in changed_body), "Expected MSRV entry to be removed from Changed section"
assert not any("Details that should not appear" in line for line in breaking_body), (
"Expected verbose breaking-change body details to be trimmed"
)
def test_breaking_changes_promoted_from_changes_section_and_empty_section_removed(self):
content = """# Changelog
## v1.0.0
### Changes
- **Bump MSRV to 1.92.0** [`abcdef123`](https://github.com/acgetchell/delaunay/commit/abcdef123)
Details that should not appear in the breaking-changes summary.
"""
processed = _ReleaseNotesPostProcessor.process(content)
lines = processed.splitlines()
assert "### ⚠️ Breaking Changes" in processed, "Expected breaking changes section to be present"
assert "### Changes" not in processed, "Expected empty Changes section to be removed after promotion"
breaking_body = _section_body(lines, "### ⚠️ Breaking Changes")
assert any("MSRV" in line for line in breaking_body), "Expected MSRV entry to be present in breaking changes section"
assert not any("Details that should not appear" in line for line in breaking_body), (
"Expected verbose breaking-change body details to be trimmed"
)
def test_breaking_changes_detects_escaped_markdown_identifiers(self):
content = """# Changelog
## v1.0.0
### Changed
- **Switch to insert\\_transactional** [`abcdef123`](https://github.com/acgetchell/delaunay/commit/abcdef123)
Extra details that should not appear in the breaking-changes summary.
- **Regular change** [`123456789`](https://github.com/acgetchell/delaunay/commit/123456789)
"""
processed = _ReleaseNotesPostProcessor.process(content)
lines = processed.splitlines()
assert "### ⚠️ Breaking Changes" in processed, "Expected breaking changes section to be present"
breaking_body = _section_body(lines, "### ⚠️ Breaking Changes")
assert any("insert\\_transactional" in line for line in breaking_body), "Expected escaped identifier entry to be recognized as breaking"
assert not any("Extra details that should not appear" in line for line in breaking_body), (
"Expected verbose breaking-change body details to be trimmed"
)
changed_body = _section_body(lines, "### Changed")
assert not any("insert\\_transactional" in line for line in changed_body), "Expected breaking-change entry to be removed from Changed section"
def test_breaking_changes_detects_removed_public_api(self):
content = """# Changelog
## v1.0.0
### Changed
- **Removed public API: Foo::bar()** [`abcdef123`](https://github.com/acgetchell/delaunay/commit/abcdef123)
- **Regular change** [`123456789`](https://github.com/acgetchell/delaunay/commit/123456789)
"""
processed = _ReleaseNotesPostProcessor.process(content)
lines = processed.splitlines()
breaking_body = _section_body(lines, "### ⚠️ Breaking Changes")
assert any("Removed public API" in line for line in breaking_body), (
"Expected removed-public-API entry to be present in breaking changes section"
)
changed_body = _section_body(lines, "### Changed")
assert not any("Removed public API" in line for line in changed_body), "Expected removed-public-API entry to be removed from Changed section"
def test_breaking_changes_detects_incompatible_language(self):
content = """# Changelog
## v1.0.0
### Changed
- **Incompatible change: Foo now requires Bar** [`abcdef123`](https://github.com/acgetchell/delaunay/commit/abcdef123)
- **Regular change** [`123456789`](https://github.com/acgetchell/delaunay/commit/123456789)
"""
processed = _ReleaseNotesPostProcessor.process(content)
lines = processed.splitlines()
breaking_body = _section_body(lines, "### ⚠️ Breaking Changes")
assert any("Incompatible change" in line for line in breaking_body), (
"Expected incompatible-change entry to be present in breaking changes section"
)
changed_body = _section_body(lines, "### Changed")
assert not any("Incompatible change" in line for line in changed_body), (
"Expected incompatible-change entry to be removed from Changed section"
)
def test_commit_links_attached_to_top_level_only(self):
top_sha = "1234567890123456789012345678901234567890" nested_sha = "9876543210987654321098765432109876543210"
content = f"""# Changelog
## v1.0.0
### Added
- **Top level item**
[`{top_sha}`](https://github.com/acgetchell/delaunay/commit/{top_sha})
- Nested item
[`{nested_sha}`](https://github.com/acgetchell/delaunay/commit/{nested_sha})
"""
processed = _ReleaseNotesPostProcessor.process(content)
assert "`1234567`" in processed, "Expected top-level bullet to keep an attached (shortened) commit link"
assert "`9876543`" not in processed, "Expected nested bullet commit links to be dropped"
commit_only_re = re.compile(r"^\s*\[`[a-f0-9]{7,40}`\]\([^)]*\)\s*$")
assert not any(commit_only_re.match(line) for line in processed.splitlines()), "Expected no standalone commit-link-only lines to remain"
def test_added_section_consolidates_many_entries_from_same_commit(self):
sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" url = f"https://github.com/acgetchell/delaunay/commit/{sha}"
content = f"""# Changelog
## v1.0.0
### Added
- **feat: Introduce new API surface** [`{sha}`]({url})
- **Add helper function** [`{sha}`]({url})
- **Add another helper** [`{sha}`]({url})
- **Add tests for new API** [`{sha}`]({url})
"""
processed = _ReleaseNotesPostProcessor.process(content)
lines = processed.splitlines()
added_body = _section_body(lines, "### Added")
top_level_bullets = [line for line in added_body if line.startswith("- **")]
assert len(top_level_bullets) == 1, "Expected Added section to consolidate into a single top-level bullet"
assert "Add helper function" in processed, "Expected helper entry to be preserved"
assert "Add another helper" in processed, "Expected helper entry to be preserved"
assert "Add tests for new API" in processed, "Expected tests entry to be preserved"
assert " - Tests" in processed, "Expected consolidated output to include a Tests bucket"
assert " - Other" in processed, "Expected consolidated output to include an Other bucket"
def test_wording_normalization_replacements(self):
content = """# Changelog
## v1.0.0
### Changed
- **Fix retriable dets edge case** [`beefbeef0`](https://github.com/acgetchell/delaunay/commit/beefbeef0)
"""
processed = _ReleaseNotesPostProcessor.process(content)
assert "retryable" in processed, "Expected 'retriable' to be normalized to 'retryable'"
assert "determinants" in processed, "Expected 'dets' to be normalized to 'determinants'"
assert "retriable" not in processed, "Expected original word 'retriable' to be removed"
assert re.search(r"\bdets\b", processed, flags=re.IGNORECASE) is None, "Expected original token 'dets' to be removed"