Option C Tools
Loading...
Searching...
No Matches
test_changelog.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_git/test_changelog.py
4
5"""
6Purpose
7-------
8Tests for the changelog generator (Phase 4E — G-E1, G-E2, G-E10).
9
10Responsibilities
11----------------
12- Validate ``collect_changelog_entries`` parsing and filtering.
13- Validate ``format_changelog`` output format (Keep a Changelog).
14- Validate ``changelog_to_json`` structured output.
15- Integration tests for ``oct git changelog`` CLI command.
16
17Diagnostics
18-----------
19Domain: GIT-TESTS
20Levels:
21 L2 — test lifecycle
22 L3 — test details
23 L4 — deep tracing
24
25Contracts
26---------
27- All tests use the ``temp_git_project`` fixture from conftest.
28- CLI tests use subprocess invocation.
29"""
30
31import json
32import subprocess
33import sys
34from pathlib import Path
35
36import pytest
37
38from tests_git.conftest import _git, commit_file
39
40
41# =====================================================================
42# Unit tests — collect_changelog_entries
43# =====================================================================
44
45
47 """Tests for collect_changelog_entries()."""
48
49 def test_feat_and_fix_commits(self, temp_git_project: Path) -> None:
50 """Feat and fix commits produce correct sections."""
51 from oct.git.changelog import collect_changelog_entries
52
53 _git(temp_git_project, "checkout", "-b", "feature/test")
54 commit_file(temp_git_project, "src/a.py", "# a\n", "feat: add feature A")
55 commit_file(temp_git_project, "src/b.py", "# b\n", "fix: resolve crash on startup")
56
57 entries = collect_changelog_entries(temp_git_project)
58 sections = [e.section for e in entries]
59 assert "Added" in sections
60 assert "Fixed" in sections
61
62 def test_non_conventional_skipped(self, temp_git_project: Path) -> None:
63 """Non-conventional commit messages are silently skipped."""
64 from oct.git.changelog import collect_changelog_entries
65
66 _git(temp_git_project, "checkout", "-b", "feature/test2")
67 commit_file(temp_git_project, "src/c.py", "# c\n", "just a random message")
68 commit_file(temp_git_project, "src/d.py", "# d\n", "feat: real feature")
69
70 entries = collect_changelog_entries(temp_git_project)
71 assert len(entries) == 1
72 assert entries[0].section == "Added"
73
74 def test_since_ref_limits_scope(self, temp_git_project: Path) -> None:
75 """--since ref limits entries to commits after ref."""
76 from oct.git.changelog import collect_changelog_entries
77
78 _git(temp_git_project, "checkout", "-b", "feature/test3")
79 commit_file(temp_git_project, "src/e.py", "# e\n", "feat: old feature")
80 # Tag here as a boundary.
81 _git(temp_git_project, "tag", "v0.1.0")
82 commit_file(temp_git_project, "src/f.py", "# f\n", "feat: new feature")
83
84 entries = collect_changelog_entries(temp_git_project, since_ref="v0.1.0")
85 assert len(entries) == 1
86 assert entries[0].subject == "new feature"
87
88 def test_empty_repo_returns_empty(self, tmp_path: Path) -> None:
89 """Fresh repo with no conventional commits returns empty."""
90 from oct.git.changelog import collect_changelog_entries
91
92 repo = tmp_path / "empty"
93 repo.mkdir()
94 _git(repo, "init", "-b", "main")
95 _git(repo, "config", "user.email", "t@t.com")
96 _git(repo, "config", "user.name", "T")
97 (repo / "f.txt").write_text("x", encoding="utf-8")
98 _git(repo, "add", ".")
99 _git(repo, "commit", "-m", "initial", "--quiet")
100
101 entries = collect_changelog_entries(repo)
102 assert entries == []
103
104 def test_scope_preserved(self, temp_git_project: Path) -> None:
105 """Scopes in commit messages are preserved in entries."""
106 from oct.git.changelog import collect_changelog_entries
107
108 _git(temp_git_project, "checkout", "-b", "feature/scope")
109 commit_file(temp_git_project, "src/g.py", "# g\n", "feat(auth): add token validation")
110
111 entries = collect_changelog_entries(temp_git_project)
112 scoped = [e for e in entries if e.scope == "auth"]
113 assert len(scoped) == 1
114 assert scoped[0].subject == "add token validation"
115
116 def test_breaking_change_detected(self, temp_git_project: Path) -> None:
117 """Breaking changes are detected from ! marker."""
118 from oct.git.changelog import collect_changelog_entries
119
120 _git(temp_git_project, "checkout", "-b", "feature/breaking")
121 commit_file(temp_git_project, "src/h.py", "# h\n", "feat!: remove deprecated API")
122
123 entries = collect_changelog_entries(temp_git_project)
124 breaking = [e for e in entries if e.breaking]
125 assert len(breaking) == 1
126
127
128# =====================================================================
129# Unit tests — format_changelog
130# =====================================================================
131
132
134 """Tests for format_changelog()."""
135
137 """Entries are grouped by section in canonical order."""
138 from oct.git.changelog import ChangelogEntry, format_changelog
139
140 entries = [
141 ChangelogEntry(section="Fixed", scope=None, subject="fix bug", breaking=False, sha="abc"),
142 ChangelogEntry(section="Added", scope=None, subject="add thing", breaking=False, sha="def"),
143 ChangelogEntry(section="Security", scope=None, subject="patch vuln", breaking=False, sha="ghi"),
144 ]
145 text = format_changelog(entries)
146 # Added should appear before Fixed, and Fixed before Security.
147 added_pos = text.index("### Added")
148 fixed_pos = text.index("### Fixed")
149 security_pos = text.index("### Security")
150 assert added_pos < fixed_pos < security_pos
151
153 """--release produces version header with date."""
154 from oct.git.changelog import ChangelogEntry, format_changelog
155
156 entries = [
157 ChangelogEntry(section="Added", scope=None, subject="new", breaking=False, sha="abc"),
158 ]
159 text = format_changelog(entries, version="v1.0.0")
160 assert "## [v1.0.0]" in text
161
162 def test_unreleased_header(self) -> None:
163 """Without version, header is [Unreleased]."""
164 from oct.git.changelog import ChangelogEntry, format_changelog
165
166 entries = [
167 ChangelogEntry(section="Added", scope=None, subject="new", breaking=False, sha="abc"),
168 ]
169 text = format_changelog(entries)
170 assert "## [Unreleased]" in text
171
172 def test_breaking_prefix(self) -> None:
173 """Breaking changes get BREAKING prefix."""
174 from oct.git.changelog import ChangelogEntry, format_changelog
175
176 entries = [
177 ChangelogEntry(section="Added", scope=None, subject="remove old API", breaking=True, sha="abc"),
178 ]
179 text = format_changelog(entries)
180 assert "**BREAKING:**" in text
181
182 def test_empty_entries(self) -> None:
183 """Empty entries produce minimal valid output."""
184 from oct.git.changelog import format_changelog
185
186 text = format_changelog([])
187 assert "## [Unreleased]" in text
188 assert "No notable changes." in text
189
190
191# =====================================================================
192# Unit tests — changelog_to_json
193# =====================================================================
194
195
197 """Tests for changelog_to_json()."""
198
199 def test_json_structure(self) -> None:
200 """JSON output has expected schema."""
201 from oct.git.changelog import ChangelogEntry, changelog_to_json
202
203 entries = [
204 ChangelogEntry(section="Added", scope="auth", subject="tokens", breaking=False, sha="a1b"),
205 ChangelogEntry(section="Fixed", scope=None, subject="crash", breaking=False, sha="c2d"),
206 ]
207 data = changelog_to_json(entries, version="v2.0.0")
208 assert data["version"] == "v2.0.0"
209 assert data["total_entries"] == 2
210 assert "Added" in data["sections"]
211 assert "Fixed" in data["sections"]
212
213 def test_json_unreleased(self) -> None:
214 """Without version, JSON shows 'Unreleased'."""
215 from oct.git.changelog import changelog_to_json
216
217 data = changelog_to_json([], version=None)
218 assert data["version"] == "Unreleased"
219
220
221# =====================================================================
222# CLI integration tests — oct git changelog
223# =====================================================================
224
225
226def _run_changelog(root: Path, *extra_args: str) -> subprocess.CompletedProcess:
227 """Run ``oct git changelog`` as a subprocess."""
228 return subprocess.run(
229 [sys.executable, "-m", "oct", "git", "changelog", *extra_args],
230 cwd=root,
231 capture_output=True,
232 text=True,
233 )
234
235
237 """CLI integration tests for oct git changelog."""
238
239 def test_dry_run(self, temp_git_project: Path) -> None:
240 """--dry-run prints changelog to stdout."""
241 _git(temp_git_project, "checkout", "-b", "feature/cl")
242 commit_file(temp_git_project, "src/x.py", "# x\n", "feat: add X")
243
244 result = _run_changelog(temp_git_project, "--dry-run")
245 assert result.returncode == 0
246 assert "Unreleased" in result.stdout or "Added" in result.stdout
247
248 def test_json_output(self, temp_git_project: Path) -> None:
249 """--json produces valid JSON."""
250 _git(temp_git_project, "checkout", "-b", "feature/json")
251 commit_file(temp_git_project, "src/y.py", "# y\n", "fix: patch Y")
252
253 result = _run_changelog(temp_git_project, "--json")
254 assert result.returncode == 0
255 data = json.loads(result.stdout)
256 assert "version" in data
257 assert "sections" in data
258
259 def test_release_writes_file(self, temp_git_project: Path) -> None:
260 """--release writes CHANGELOG.md."""
261 _git(temp_git_project, "checkout", "-b", "feature/release")
262 commit_file(temp_git_project, "src/z.py", "# z\n", "feat: add Z")
263
264 result = _run_changelog(temp_git_project, "--release", "v1.0.0")
265 assert result.returncode == 0
266
267 changelog = temp_git_project / "CHANGELOG.md"
268 assert changelog.is_file()
269 content = changelog.read_text(encoding="utf-8")
270 assert "v1.0.0" in content
271
272 def test_since_flag(self, temp_git_project: Path) -> None:
273 """--since limits scope."""
274 _git(temp_git_project, "checkout", "-b", "feature/since")
275 commit_file(temp_git_project, "src/s1.py", "# s1\n", "feat: first")
276 _git(temp_git_project, "tag", "v0.0.1")
277 commit_file(temp_git_project, "src/s2.py", "# s2\n", "feat: second")
278
279 result = _run_changelog(temp_git_project, "--json", "--since", "v0.0.1")
280 assert result.returncode == 0
281 data = json.loads(result.stdout)
282 assert data["total_entries"] == 1
283
284 def test_audit_trail(self, temp_git_project: Path) -> None:
285 """Changelog command writes audit record."""
286 _git(temp_git_project, "checkout", "-b", "feature/audit")
287 commit_file(temp_git_project, "src/a.py", "# a\n", "feat: audited")
288
289 result = _run_changelog(temp_git_project, "--dry-run")
290 assert result.returncode == 0
291 # Audit file should exist in .oct-audit/.
292 audit_dir = temp_git_project / ".oct-audit"
293 if audit_dir.is_dir():
294 audit_files = list(audit_dir.glob("*.jsonl"))
295 assert len(audit_files) >= 1
None test_audit_trail(self, Path temp_git_project)
None test_since_flag(self, Path temp_git_project)
None test_json_output(self, Path temp_git_project)
None test_dry_run(self, Path temp_git_project)
None test_release_writes_file(self, Path temp_git_project)
None test_since_ref_limits_scope(self, Path temp_git_project)
None test_scope_preserved(self, Path temp_git_project)
None test_non_conventional_skipped(self, Path temp_git_project)
None test_empty_repo_returns_empty(self, Path tmp_path)
None test_feat_and_fix_commits(self, Path temp_git_project)
None test_breaking_change_detected(self, Path temp_git_project)
subprocess.CompletedProcess _run_changelog(Path root, *str extra_args)