216 lines
8.8 KiB
Python
216 lines
8.8 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Tests for release comments - the explicit step in a release (spec 22).
|
|
|
|
The comment is a gate, and its designator has to make two releases on the same day
|
|
unambiguous. Those are the two properties worth pinning.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from umbraaudio.release_comments import (
|
|
STATUS_APPROVED,
|
|
STATUS_DRAFT,
|
|
CommentError,
|
|
build_designator,
|
|
build_slug,
|
|
comment_path,
|
|
create_comment,
|
|
draft_comment_text,
|
|
list_comments,
|
|
load_comment,
|
|
next_sequence,
|
|
parse_comment,
|
|
require_approved,
|
|
update_comment,
|
|
)
|
|
|
|
T0 = datetime(2026, 10, 3, 14, 32, 5, tzinfo=UTC)
|
|
|
|
|
|
class TestDesignator:
|
|
def test_slug_carries_date_time_and_sequence(self) -> None:
|
|
assert build_slug(T0, 2) == "release-20261003-143205-02"
|
|
|
|
def test_designator_is_readable(self) -> None:
|
|
assert build_designator(T0, 2) == "2026-10-03 #2 14:32:05"
|
|
|
|
def test_sequence_is_per_date_not_global(self, tmp_path: Path) -> None:
|
|
create_comment(tmp_path, "First", now=T0)
|
|
create_comment(tmp_path, "Second", now=T0 + timedelta(seconds=30))
|
|
assert next_sequence(tmp_path, T0) == 3
|
|
# A new date starts at 1 again, because the date is part of the handle.
|
|
assert next_sequence(tmp_path, T0 + timedelta(days=1)) == 1
|
|
|
|
def test_same_day_sequence_disambiguates_two_releases(self, tmp_path: Path) -> None:
|
|
a = create_comment(tmp_path, "First attempt", now=T0)
|
|
b = create_comment(tmp_path, "Corrected attempt", now=T0 + timedelta(hours=1))
|
|
assert a.sequence == 1 and b.sequence == 2
|
|
assert a.date == b.date
|
|
assert a.slug != b.slug
|
|
assert "#2" in b.designator
|
|
|
|
def test_two_comments_in_the_same_second_still_get_distinct_slugs(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
"""The sequence is what makes this safe, not a timestamp collision check."""
|
|
a = create_comment(tmp_path, "One", now=T0)
|
|
b = create_comment(tmp_path, "Two", now=T0)
|
|
assert a.slug != b.slug
|
|
assert (a.sequence, b.sequence) == (1, 2)
|
|
assert load_comment(tmp_path, a.slug).summary == "One"
|
|
assert load_comment(tmp_path, b.slug).summary == "Two"
|
|
|
|
|
|
class TestParseComment:
|
|
def test_first_line_is_the_summary(self) -> None:
|
|
summary, body = parse_comment("Fix the padding\n\n2s lead and trail.")
|
|
assert summary == "Fix the padding"
|
|
assert body == "2s lead and trail."
|
|
|
|
def test_body_only_comment(self) -> None:
|
|
summary, body = parse_comment("Just a summary")
|
|
assert body == ""
|
|
|
|
def test_empty_comment_is_refused(self) -> None:
|
|
with pytest.raises(CommentError, match="at least a summary"):
|
|
parse_comment(" \n \n")
|
|
|
|
def test_leading_blank_lines_are_ignored(self) -> None:
|
|
"""Padding a note must not silently become the summary."""
|
|
summary, body = parse_comment("\n\nShipped the fix\n\nDetail.")
|
|
assert summary == "Shipped the fix"
|
|
assert body == "Detail."
|
|
|
|
|
|
class TestLifecycle:
|
|
def test_new_comment_is_a_draft(self, tmp_path: Path) -> None:
|
|
comment = create_comment(tmp_path, "Ship it", now=T0)
|
|
assert comment.status == STATUS_DRAFT
|
|
assert comment.approved_at is None
|
|
|
|
def test_draft_can_be_edited(self, tmp_path: Path) -> None:
|
|
created = create_comment(tmp_path, "Wrong summary", now=T0)
|
|
edited = update_comment(tmp_path, created.slug, text="Right summary\nwith detail")
|
|
assert edited.summary == "Right summary"
|
|
assert edited.body == "with detail"
|
|
|
|
def test_approved_comment_is_frozen(self, tmp_path: Path) -> None:
|
|
created = create_comment(tmp_path, "Original", now=T0)
|
|
update_comment(tmp_path, created.slug, status=STATUS_APPROVED)
|
|
with pytest.raises(CommentError, match="frozen"):
|
|
update_comment(tmp_path, created.slug, text="Changed my mind")
|
|
# ...and the stored text is genuinely unchanged.
|
|
assert load_comment(tmp_path, created.slug).summary == "Original"
|
|
|
|
def test_approve_records_the_time(self, tmp_path: Path) -> None:
|
|
created = create_comment(tmp_path, "Ready", now=T0)
|
|
approved = update_comment(tmp_path, created.slug, status=STATUS_APPROVED)
|
|
assert approved.status == STATUS_APPROVED
|
|
assert approved.approved_at is not None
|
|
|
|
def test_cannot_change_text_while_approving(self, tmp_path: Path) -> None:
|
|
created = create_comment(tmp_path, "As drafted", now=T0)
|
|
with pytest.raises(CommentError, match="Edit the draft first"):
|
|
update_comment(tmp_path, created.slug, text="Different", status=STATUS_APPROVED)
|
|
|
|
def test_publishing_requires_an_approved_comment(self, tmp_path: Path) -> None:
|
|
created = create_comment(tmp_path, "Draft only", now=T0)
|
|
with pytest.raises(CommentError, match="DRAFT"):
|
|
require_approved(tmp_path, created.slug)
|
|
|
|
def test_publishing_requires_a_comment_to_exist(self, tmp_path: Path) -> None:
|
|
with pytest.raises(CommentError, match="no release comment"):
|
|
require_approved(tmp_path, "release-20261003-000000-01")
|
|
|
|
def test_approved_comment_satisfies_the_gate(self, tmp_path: Path) -> None:
|
|
created = create_comment(tmp_path, "Good to go", now=T0)
|
|
update_comment(tmp_path, created.slug, status=STATUS_APPROVED)
|
|
assert require_approved(tmp_path, created.slug).slug == created.slug
|
|
|
|
def test_approved_at_creation_is_immediately_publishable(self, tmp_path: Path) -> None:
|
|
created = create_comment(tmp_path, "Pre-approved", now=T0, status=STATUS_APPROVED)
|
|
assert require_approved(tmp_path, created.slug).status == STATUS_APPROVED
|
|
|
|
|
|
class TestStorage:
|
|
def test_round_trips_through_disk(self, tmp_path: Path) -> None:
|
|
created = create_comment(
|
|
tmp_path, "Summary line\n\nBody text.", author="worstauthor", now=T0,
|
|
facts={"chapters": 32},
|
|
)
|
|
loaded = load_comment(tmp_path, created.slug)
|
|
assert loaded.summary == "Summary line"
|
|
assert loaded.body == "Body text."
|
|
assert loaded.facts["chapters"] == 32
|
|
assert loaded.author == "worstauthor"
|
|
|
|
def test_listing_is_newest_first(self, tmp_path: Path) -> None:
|
|
a = create_comment(tmp_path, "First", now=T0)
|
|
b = create_comment(tmp_path, "Second", now=T0 + timedelta(minutes=5))
|
|
rows = list_comments(tmp_path)
|
|
assert [r["slug"] for r in rows] == [b.slug, a.slug]
|
|
|
|
def test_missing_comment_is_none_not_an_error(self, tmp_path: Path) -> None:
|
|
assert load_comment(tmp_path, "release-19700101-000000-01") is None
|
|
|
|
def test_no_tmp_files_left_behind(self, tmp_path: Path) -> None:
|
|
create_comment(tmp_path, "Clean write", now=T0)
|
|
assert list(comment_path(tmp_path, "release-20261003-143205-01").parent.glob("*.tmp")) == []
|
|
|
|
def test_listing_empty_dir(self, tmp_path: Path) -> None:
|
|
assert list_comments(tmp_path) == []
|
|
|
|
|
|
class TestDraftFromBuild:
|
|
def _record(self) -> dict:
|
|
return {
|
|
"work": "Deal Hunter",
|
|
"edition_number": 1,
|
|
"package": {
|
|
"object_key": "export/Deal_Hunter.m4b",
|
|
"bytes": 325875309,
|
|
"built_at": "2026-09-09T11:21:11+00:00",
|
|
"sha256": "abc",
|
|
},
|
|
"delivery_profile": {
|
|
"sample_rate": 44100, "channels": 1, "bit_rate_kbps": 192,
|
|
"lead_silence_s": 2.0, "trail_silence_s": 2.0, "target_rms_db": -20.5,
|
|
},
|
|
"approvals": {
|
|
"voices_explicitly_approved": 24,
|
|
"voices_implicitly_approved": 29,
|
|
"voices_in_profile_without_lines": 10,
|
|
},
|
|
"chapters": [
|
|
{"mp3": {"loudness_ok": True, "peak_ok": True}, "warnings": ["4.0s longer"]},
|
|
{"mp3": {"loudness_ok": True, "peak_ok": True}, "warnings": []},
|
|
],
|
|
}
|
|
|
|
def test_comment_states_the_measured_facts(self) -> None:
|
|
text = draft_comment_text(self._record(), "worstauthor")
|
|
assert "Deal Hunter edition 1" in text
|
|
assert "2 chapters pinned" in text
|
|
assert "44100 Hz" in text
|
|
assert "+2.0s lead" in text
|
|
assert "24 explicitly approved" in text
|
|
assert "29 approved implicitly" in text
|
|
|
|
def test_comment_names_the_post_approval_delta(self) -> None:
|
|
text = draft_comment_text(self._record(), "worstauthor")
|
|
assert "1 chapters shipped longer or resampled" in text
|
|
|
|
def test_comment_is_first_line_summary(self) -> None:
|
|
summary, _ = parse_comment(draft_comment_text(self._record(), "x"))
|
|
assert "Deal Hunter" in summary
|