Files
VoiceStudio/tests/test_changelog_parse.py
16294fed44 feat(updates): data-safe updates — pre-migration DB backups, guarded venv heal, release notes + changelog reader (#909)
Backend:
- core/db_backup.py: WAL-safe SQLite snapshot to omnivoice.db.backup-<version>-<n>
  before pending alembic migrations run; keep newest 3, prune older; skip >500MB
  with a log line. Restore is never automatic.
- core/db.py: _run_alembic_upgrade now plans the run (up_to_date / pending /
  unknown_revision), snapshots first when migrations will execute, and raises
  MigrationError on a mid-flight failure — startup stops with the backup path
  named instead of continuing on a half-migrated DB. The #552/#547
  unknown-revision class stays non-fatal (warn + additive reconcile).
- core/changelog.py + GET /api/settings/changelog: parse the shipped
  CHANGELOG.md (single-line and wrapped bullet styles) into structured releases.
- GET /api/settings/db-backup: newest pre-migration backup for the panel.

Rust (bootstrap.rs):
- #314 heal guard: an exit-signature match alone can no longer delete the venv —
  venv_rebuild_justified requires a structural problem or a failed direct
  interpreter probe; a venv that probes healthy is kept and the real error
  surfaced. Drift/repair remains in-place `uv sync` (non-destructive).
- CHANGELOG.md now ships as a bundle resource and is copied/refreshed into the
  project dir so the changelog endpoint works in packaged installs.

Frontend (Settings → Updates):
- Available update shows its actual release notes (updater metadata body)
  through a safe markdown-lite renderer (text nodes only, refs stay plain).
- "Your data is backed up before every update" line with the latest backup
  timestamp from the new endpoint.
- "What's new" changelog reader (accordion, newest expanded) over the shipped
  CHANGELOG.md; GitHub releases list reuses the same renderer.
- One-time, non-blocking "What's new" footer pill after an update
  (persisted last-seen version; fresh installs baseline silently).
- All strings via t() with en keys (other locales fall back to English).

Tests: db backup/rotation/failure-path units, migration-safety units, changelog
parser (both bullet styles + real CHANGELOG.md), endpoint tests, route
inventory regenerated, Rust decision-logic + probe tests, vitest suites for
renderer/viewer/panel/pill logic.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:52:17 +05:30

121 lines
4.2 KiB
Python

"""CHANGELOG.md → structured release notes (core.changelog) — feat/safe-updates.
The parser must handle BOTH bullet styles that exist in the real changelog:
recent sections write each bullet as one long line; older sections hard-wrap
bullets across indented continuation lines. It also feeds the Settings →
Updates "What's new" viewer, so the shipped CHANGELOG.md itself is a fixture.
"""
import os
from core import changelog
_REPO_CHANGELOG = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "CHANGELOG.md"
)
_SAMPLE = """# Changelog
All notable changes to OmniVoice Studio.
## [Unreleased]
- **Not shipped yet.** Should never appear in the viewer.
## [0.3.9] — 2026-07-02
The dictation release — one-paragraph headline.
### Added
- **Dictation, rebuilt.** Live waveform, ~0.5 s commit, clean punctuation. (#123)
- **LLM provider testing.** Latency + classified errors. (#887)
### Fixed
- **CUDA transcription works on packaged installs.** The compat libs now install at launch. (#827, #869)
## [0.3.8] — 2026-07-01
A stability-focused release that makes first-run and Windows "just work," ships
**live dictation** and more.
### Added
- **"Autofit" translation quality — the dub keeps the video's timing.** A new
quality alongside Fast and Cinematic: the LLM rewrites each translated line so
it fits. (#838)
- **A new LLM Providers settings page.** One page for **16 providers**. (#850)
## [0.3.7] — 2026-06-20
### Fixed
- **Old bug.** Squashed. (#700)
## [0.3.6] — 2026-06-15
### Changed
- **Older still.** (#600)
"""
def test_parses_versions_newest_first_and_skips_unreleased():
releases = changelog.parse_changelog(_SAMPLE)
assert [r["version"] for r in releases] == ["0.3.9", "0.3.8", "0.3.7", "0.3.6"]
assert releases[0]["date"] == "2026-07-02"
assert all("Not shipped yet" not in b
for r in releases for s in r["sections"] for b in s["bullets"])
def test_single_line_bullets_parse_intact():
r = changelog.parse_changelog(_SAMPLE)[0]
assert r["intro"] == "The dictation release — one-paragraph headline."
added = r["sections"][0]
assert added["title"] == "Added"
assert added["bullets"][0].startswith("**Dictation, rebuilt.**")
assert added["bullets"][1].endswith("(#887)")
assert [s["title"] for s in r["sections"]] == ["Added", "Fixed"]
def test_wrapped_bullets_are_joined_to_one_logical_line():
r = changelog.parse_changelog(_SAMPLE)[1] # 0.3.8, the wrapped style
bullets = r["sections"][0]["bullets"]
assert len(bullets) == 2
# Continuation lines join with single spaces — no newlines, no double spaces.
assert "\n" not in bullets[0]
assert "quality alongside Fast and Cinematic" in bullets[0]
assert bullets[0].endswith("(#838)")
# The wrapped intro paragraph joins too.
assert r["intro"].startswith("A stability-focused release")
assert "ships **live dictation**" in r["intro"]
def test_limit_versions_caps_output():
assert len(changelog.parse_changelog(_SAMPLE, limit_versions=2)) == 2
assert len(changelog.parse_changelog(_SAMPLE, limit_versions=50)) == 4
def test_real_repo_changelog_parses():
"""The shipped changelog is the production input — it must parse into
non-empty structured releases with the house sections."""
with open(_REPO_CHANGELOG, encoding="utf-8") as fh:
releases = changelog.parse_changelog(fh.read(), limit_versions=5)
assert len(releases) == 5
for r in releases:
assert r["version"][0].isdigit()
assert r["sections"], f"release {r['version']} parsed with no sections"
assert all(s["bullets"] for s in r["sections"])
# Newest-first ordering matches the file order.
versions = [r["version"] for r in releases]
assert versions == sorted(versions, key=lambda v: [int(x) for x in v.split("-")[0].split(".")], reverse=True)
def test_changelog_path_env_override(tmp_path, monkeypatch):
f = tmp_path / "CHANGELOG.md"
f.write_text("## [1.0.0] — 2027-01-01\n### Added\n- **X.** (#1)\n", encoding="utf-8")
monkeypatch.setenv("OMNIVOICE_CHANGELOG", str(f))
assert changelog.changelog_path() == str(f)
monkeypatch.setenv("OMNIVOICE_CHANGELOG", str(tmp_path / "missing.md"))
assert changelog.changelog_path() is None