mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
test: planner tests + domain regression tests
51 tests for the unified conversion planner: - build_conversion_plan: direct text, voice markers, chunks, chapters, intro/outro, output layout - Domain regression: chapter parsing, voice markers, TTSContext, voice resolution, intro/outro, output paths, subtitles - All tests use domain functions only (no UI, no TTS, no audio I/O)
This commit is contained in:
@@ -1,44 +1,302 @@
|
|||||||
"""Regression tests for conversion planning logic.
|
"""Tests for the unified conversion planner (build_conversion_plan).
|
||||||
|
|
||||||
These tests verify that domain functions produce correct chapter plans,
|
Verifies that the planner correctly handles:
|
||||||
segment plans, and voice marker splits. They serve as a regression net
|
- Plain text conversion
|
||||||
for the upcoming conversion flow unification refactor.
|
- Voice markers (PyQt style)
|
||||||
|
- Chapter parsing
|
||||||
|
- Chunks (WebUI style)
|
||||||
|
- Intro/outro
|
||||||
|
- Output layout
|
||||||
|
- Edge cases (empty text, no chapters, etc.)
|
||||||
|
|
||||||
All tests use domain functions only — no UI, no TTS, no audio I/O.
|
Also includes domain-level regression tests for the underlying functions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import os
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from abogen.domain.text_chapters import parse_chapters_from_text
|
import pytest
|
||||||
from abogen.domain.chapter_titles import format_spoken_chapter_title
|
|
||||||
from abogen.domain.normalization import TTSContext
|
from abogen.application.conversion_models import (
|
||||||
from abogen.domain.voice_resolution import (
|
ChapterPlan,
|
||||||
resolve_fallback_voice_spec,
|
ConversionPlan,
|
||||||
spec_to_voice_ids,
|
IntroOutroSpec,
|
||||||
|
OutputLayout,
|
||||||
|
SegmentPlan,
|
||||||
)
|
)
|
||||||
from abogen.domain.intro_outro import resolve_intro, resolve_outro
|
from abogen.application.conversion_planner import build_conversion_plan
|
||||||
from abogen.domain.output_paths import (
|
from abogen.application.conversion_request import ConversionRequest
|
||||||
resolve_output_directory,
|
|
||||||
resolve_unique_path,
|
|
||||||
sanitize_output_stem,
|
|
||||||
)
|
|
||||||
from abogen.domain.subtitle_generation import process_subtitle_tokens
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Chapter Parsing ───────────────────────────────────────────────
|
class TestBuildConversionPlan:
|
||||||
|
"""Tests for the main build_conversion_plan function."""
|
||||||
|
|
||||||
|
def test_direct_text_simple(self):
|
||||||
|
"""Plain text without markers or chapters."""
|
||||||
|
req = ConversionRequest(direct_text="Hello world", voice="M1")
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
assert isinstance(plan, ConversionPlan)
|
||||||
|
assert len(plan.chapters) == 1
|
||||||
|
assert plan.chapters[0].segments[0].text == "Hello world"
|
||||||
|
assert plan.chapters[0].segments[0].voice_spec == "M1"
|
||||||
|
assert plan.chapters[0].segments[0].source == "chapter"
|
||||||
|
|
||||||
|
def test_direct_text_with_chapters(self):
|
||||||
|
"""Text with chapter markers is split into chapters."""
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="<<CHAPTER_MARKER:Chapter 1>>\nText A\n<<CHAPTER_MARKER:Chapter 2>>\nText B",
|
||||||
|
voice="M1",
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
assert len(plan.chapters) == 2
|
||||||
|
assert plan.chapters[0].title == "Chapter 1"
|
||||||
|
assert plan.chapters[1].title == "Chapter 2"
|
||||||
|
|
||||||
|
def test_voice_markers(self):
|
||||||
|
"""Voice markers are detected and create separate segments."""
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="Hello <<VOICE:F1>> World", voice="M1"
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
segments = plan.chapters[0].segments
|
||||||
|
assert len(segments) == 2
|
||||||
|
assert segments[0].text == "Hello"
|
||||||
|
assert segments[0].source == "voice_marker"
|
||||||
|
assert segments[1].text == "World"
|
||||||
|
assert segments[1].source == "voice_marker"
|
||||||
|
|
||||||
|
def test_chunks(self):
|
||||||
|
"""Chunks from WebUI are converted to segments."""
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="Some text",
|
||||||
|
voice="M1",
|
||||||
|
chunks=[
|
||||||
|
{"text": "Chunk 1", "speaker_id": "narrator"},
|
||||||
|
{"text": "Chunk 2", "speaker_id": "narrator"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
segments = plan.chapters[0].segments
|
||||||
|
assert len(segments) == 2
|
||||||
|
assert segments[0].text == "Chunk 1"
|
||||||
|
assert segments[0].source == "chunk"
|
||||||
|
assert segments[1].text == "Chunk 2"
|
||||||
|
|
||||||
|
def test_chunks_with_voice(self):
|
||||||
|
"""Chunks with per-chunk voice spec."""
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="Text",
|
||||||
|
voice="M1",
|
||||||
|
chunks=[
|
||||||
|
{"text": "Narrator speaks", "speaker_id": "narrator"},
|
||||||
|
{"text": "Character speaks", "speaker_id": "alice", "voice": "F1"},
|
||||||
|
],
|
||||||
|
speakers={"alice": {"voice": "F1"}},
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
segments = plan.chapters[0].segments
|
||||||
|
assert len(segments) == 2
|
||||||
|
assert segments[0].voice_spec == "M1"
|
||||||
|
assert segments[1].voice_spec == "F1"
|
||||||
|
|
||||||
|
def test_intro_spec(self):
|
||||||
|
"""Intro is created when read_title_intro=True."""
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="<<CHAPTER_MARKER:Chapter 1>>\nThe Great Gatsby by F. Scott Fitzgerald\nBody text",
|
||||||
|
voice="M1",
|
||||||
|
read_title_intro=True,
|
||||||
|
metadata_tags={"title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
# Intro may or may not be enabled depending on metadata resolution
|
||||||
|
assert plan.intro is None or isinstance(plan.intro, IntroOutroSpec)
|
||||||
|
|
||||||
|
def test_output_layout(self):
|
||||||
|
"""Output layout is resolved from request."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="Hello",
|
||||||
|
voice="M1",
|
||||||
|
save_mode="custom_folder",
|
||||||
|
output_folder=Path(tmpdir),
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
assert isinstance(plan.output_layout, OutputLayout)
|
||||||
|
assert plan.output_layout.parent_dir == Path(tmpdir)
|
||||||
|
|
||||||
|
def test_empty_text_raises(self):
|
||||||
|
"""Empty text should raise ValueError."""
|
||||||
|
req = ConversionRequest(direct_text="", voice="M1")
|
||||||
|
with pytest.raises(ValueError, match="No text content"):
|
||||||
|
build_conversion_plan(req)
|
||||||
|
|
||||||
|
def test_whitespace_only_raises(self):
|
||||||
|
"""Whitespace-only text should raise ValueError."""
|
||||||
|
req = ConversionRequest(direct_text=" \n \n ", voice="M1")
|
||||||
|
with pytest.raises(ValueError, match="No text content"):
|
||||||
|
build_conversion_plan(req)
|
||||||
|
|
||||||
|
def test_no_source_raises(self):
|
||||||
|
"""Request with no source should raise ValueError."""
|
||||||
|
req = ConversionRequest(voice="M1")
|
||||||
|
with pytest.raises(ValueError, match="No text content"):
|
||||||
|
build_conversion_plan(req)
|
||||||
|
|
||||||
|
def test_plan_preserves_request(self):
|
||||||
|
"""Plan should reference the original request."""
|
||||||
|
req = ConversionRequest(direct_text="Hello", voice="M1", speed=1.5)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
assert plan.request is req
|
||||||
|
assert plan.request.speed == 1.5
|
||||||
|
|
||||||
|
def test_metadata_in_plan(self):
|
||||||
|
"""Metadata from request should appear in plan."""
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="Hello",
|
||||||
|
voice="M1",
|
||||||
|
metadata_tags={"title": "Test Book", "author": "Author"},
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
assert "title" in plan.metadata
|
||||||
|
assert plan.metadata["title"] == "Test Book"
|
||||||
|
|
||||||
|
def test_chapter_index_starts_at_1(self):
|
||||||
|
"""Chapter indices should start at 1."""
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="<<CHAPTER_MARKER:Ch1>>\nText\n<<CHAPTER_MARKER:Ch2>>\nText\n<<CHAPTER_MARKER:Ch3>>\nText",
|
||||||
|
voice="M1",
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
for i, ch in enumerate(plan.chapters, 1):
|
||||||
|
assert ch.index == i
|
||||||
|
|
||||||
|
def test_chapter_body_text_preserved(self):
|
||||||
|
"""Chapter body text should be preserved in ChapterPlan."""
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="<<CHAPTER_MARKER:Chapter 1>>\nThe actual body text", voice="M1"
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
assert "The actual body text" in plan.chapters[0].body_text
|
||||||
|
|
||||||
|
def test_segment_kind_default(self):
|
||||||
|
"""Default segment kind should be 'body'."""
|
||||||
|
req = ConversionRequest(direct_text="Hello", voice="M1")
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
assert plan.chapters[0].segments[0].kind == "body"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlannerWithFileSource:
|
||||||
|
"""Tests using actual file sources (not direct_text)."""
|
||||||
|
|
||||||
|
def test_txt_file(self):
|
||||||
|
"""Planning from a .txt file."""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".txt", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
f.write("Chapter 1\nHello from file")
|
||||||
|
f.flush()
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = ConversionRequest(source_path=path, voice="M1")
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
assert len(plan.chapters) >= 1
|
||||||
|
assert "Hello from file" in plan.chapters[0].segments[0].text
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
def test_txt_file_with_voice_markers(self):
|
||||||
|
"""File with voice markers."""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".txt", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
f.write("Start <<VOICE:F1>> End")
|
||||||
|
f.flush()
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = ConversionRequest(source_path=path, voice="M1")
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
segments = plan.chapters[0].segments
|
||||||
|
assert len(segments) == 2
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlannerChapters:
|
||||||
|
"""Tests for chapter handling in the planner."""
|
||||||
|
|
||||||
|
def test_single_chapter_no_marker(self):
|
||||||
|
"""Text without markers becomes a single chapter."""
|
||||||
|
req = ConversionRequest(direct_text="Just some text", voice="M1")
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
assert len(plan.chapters) == 1
|
||||||
|
assert plan.chapters[0].title == "text"
|
||||||
|
|
||||||
|
def test_chapters_with_marker(self):
|
||||||
|
"""Chapter markers create multiple chapters."""
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="<<CHAPTER_MARKER:Ch A>>\nText A\n<<CHAPTER_MARKER:Ch B>>\nText B",
|
||||||
|
voice="M1",
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
assert len(plan.chapters) == 2
|
||||||
|
assert plan.chapters[0].title == "Ch A"
|
||||||
|
assert plan.chapters[1].title == "Ch B"
|
||||||
|
|
||||||
|
def test_chapter_voice_spec(self):
|
||||||
|
"""Chapter voice spec should come from request.voice."""
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="<<CHAPTER_MARKER:Ch 1>>\nText", voice="af_heart"
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
assert plan.chapters[0].voice_spec == "af_heart"
|
||||||
|
|
||||||
|
def test_chapters_preserve_order(self):
|
||||||
|
"""Chapters should maintain their order."""
|
||||||
|
req = ConversionRequest(
|
||||||
|
direct_text="<<CHAPTER_MARKER:Ch A>>\nText A\n<<CHAPTER_MARKER:Ch B>>\nText B\n<<CHAPTER_MARKER:Ch C>>\nText C",
|
||||||
|
voice="M1",
|
||||||
|
)
|
||||||
|
plan = build_conversion_plan(req)
|
||||||
|
|
||||||
|
titles = [ch.title for ch in plan.chapters]
|
||||||
|
assert titles == ["Ch A", "Ch B", "Ch C"]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Domain-level regression tests ─────────────────────────────────
|
||||||
|
|
||||||
class TestChapterParsing:
|
class TestChapterParsing:
|
||||||
"""Verify parse_chapters_from_text produces correct chapter structure."""
|
"""Verify parse_chapters_from_text produces correct chapter structure."""
|
||||||
|
|
||||||
def test_single_chapter_no_markers(self):
|
def test_single_chapter_no_markers(self):
|
||||||
|
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||||
text = "This is a simple text without any chapter markers."
|
text = "This is a simple text without any chapter markers."
|
||||||
chapters = parse_chapters_from_text(text, clean=False)
|
chapters = parse_chapters_from_text(text, clean=False)
|
||||||
assert len(chapters) == 1
|
assert len(chapters) == 1
|
||||||
assert chapters[0][0] # title exists
|
assert chapters[0][0]
|
||||||
assert "simple text" in chapters[0][1]
|
assert "simple text" in chapters[0][1]
|
||||||
|
|
||||||
def test_multiple_chapters_by_markers(self):
|
def test_multiple_chapters_by_markers(self):
|
||||||
|
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||||
text = """<<CHAPTER_MARKER:Chapter 1>>
|
text = """<<CHAPTER_MARKER:Chapter 1>>
|
||||||
First chapter content.
|
First chapter content.
|
||||||
|
|
||||||
@@ -51,10 +309,12 @@ Second chapter content."""
|
|||||||
assert "Chapter 2" in titles
|
assert "Chapter 2" in titles
|
||||||
|
|
||||||
def test_empty_text(self):
|
def test_empty_text(self):
|
||||||
|
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||||
chapters = parse_chapters_from_text("", clean=False)
|
chapters = parse_chapters_from_text("", clean=False)
|
||||||
assert len(chapters) >= 1 # at least one empty chapter
|
assert len(chapters) >= 1
|
||||||
|
|
||||||
def test_chapter_content_preserved(self):
|
def test_chapter_content_preserved(self):
|
||||||
|
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||||
text = """<<CHAPTER_MARKER:Chapter 1>>
|
text = """<<CHAPTER_MARKER:Chapter 1>>
|
||||||
Hello world this is chapter one.
|
Hello world this is chapter one.
|
||||||
|
|
||||||
@@ -67,6 +327,7 @@ Goodbye world this is chapter two."""
|
|||||||
assert "Goodbye world" in all_text
|
assert "Goodbye world" in all_text
|
||||||
|
|
||||||
def test_intro_before_first_marker(self):
|
def test_intro_before_first_marker(self):
|
||||||
|
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||||
text = """Introduction text here.
|
text = """Introduction text here.
|
||||||
<<CHAPTER_MARKER:Chapter 1>>
|
<<CHAPTER_MARKER:Chapter 1>>
|
||||||
Chapter content."""
|
Chapter content."""
|
||||||
@@ -76,8 +337,6 @@ Chapter content."""
|
|||||||
assert "Introduction text" in chapters[0][1]
|
assert "Introduction text" in chapters[0][1]
|
||||||
|
|
||||||
|
|
||||||
# ─── Voice Marker Splitting ────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestVoiceMarkerSplitting:
|
class TestVoiceMarkerSplitting:
|
||||||
"""Verify voice marker splitting produces correct segment structure."""
|
"""Verify voice marker splitting produces correct segment structure."""
|
||||||
|
|
||||||
@@ -86,14 +345,13 @@ class TestVoiceMarkerSplitting:
|
|||||||
text = "Just plain text without any voice markers."
|
text = "Just plain text without any voice markers."
|
||||||
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
||||||
assert len(segments) == 1
|
assert len(segments) == 1
|
||||||
assert segments[0][0] == "M1" # default voice
|
assert segments[0][0] == "M1"
|
||||||
assert "plain text" in segments[0][1]
|
assert "plain text" in segments[0][1]
|
||||||
|
|
||||||
def test_single_voice_marker(self):
|
def test_single_voice_marker(self):
|
||||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||||
text = "<<VOICE:F1>> Hello from female voice."
|
text = "<<VOICE:F1>> Hello from female voice."
|
||||||
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
||||||
# Should have at least one segment with the voice marker text
|
|
||||||
assert len(segments) >= 1
|
assert len(segments) >= 1
|
||||||
all_text = " ".join(seg[1] for seg in segments)
|
all_text = " ".join(seg[1] for seg in segments)
|
||||||
assert "Hello from female" in all_text
|
assert "Hello from female" in all_text
|
||||||
@@ -110,16 +368,14 @@ class TestVoiceMarkerSplitting:
|
|||||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||||
text = "<<VOICE:F1>> First part."
|
text = "<<VOICE:F1>> First part."
|
||||||
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
||||||
# Should return the voice used, or default if voice not recognized
|
|
||||||
assert last_voice in ("f1", "F1", "M1")
|
assert last_voice in ("f1", "F1", "M1")
|
||||||
|
|
||||||
|
|
||||||
# ─── TTSContext ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestTTSContext:
|
class TestTTSContext:
|
||||||
"""Verify TTSContext bundles normalization parameters correctly."""
|
"""Verify TTSContext bundles normalization parameters correctly."""
|
||||||
|
|
||||||
def test_default_context(self):
|
def test_default_context(self):
|
||||||
|
from abogen.domain.normalization import TTSContext
|
||||||
ctx = TTSContext()
|
ctx = TTSContext()
|
||||||
assert ctx.split_pattern
|
assert ctx.split_pattern
|
||||||
assert ctx.pronunciation_rules is None
|
assert ctx.pronunciation_rules is None
|
||||||
@@ -128,6 +384,7 @@ class TestTTSContext:
|
|||||||
assert ctx.usage_counter == {}
|
assert ctx.usage_counter == {}
|
||||||
|
|
||||||
def test_normalize_passthrough(self):
|
def test_normalize_passthrough(self):
|
||||||
|
from abogen.domain.normalization import TTSContext
|
||||||
ctx = TTSContext()
|
ctx = TTSContext()
|
||||||
text = "Hello world."
|
text = "Hello world."
|
||||||
result = ctx.normalize(text)
|
result = ctx.normalize(text)
|
||||||
@@ -135,89 +392,70 @@ class TestTTSContext:
|
|||||||
assert len(result) > 0
|
assert len(result) > 0
|
||||||
|
|
||||||
def test_normalize_with_usage_counter(self):
|
def test_normalize_with_usage_counter(self):
|
||||||
|
from abogen.domain.normalization import TTSContext
|
||||||
ctx = TTSContext()
|
ctx = TTSContext()
|
||||||
ctx.usage_counter["test_token"] = 0
|
ctx.usage_counter["test_token"] = 0
|
||||||
result = ctx.normalize("Some text.")
|
result = ctx.normalize("Some text.")
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
|
||||||
# ─── Voice Resolution ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestVoiceResolution:
|
class TestVoiceResolution:
|
||||||
"""Verify voice resolution functions produce valid specs."""
|
"""Verify voice resolution functions produce valid specs."""
|
||||||
|
|
||||||
def test_resolve_fallback_voice_spec(self):
|
def test_resolve_fallback_voice_spec(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||||
spec = resolve_fallback_voice_spec("M1", "M1", ["M1", "F1"])
|
spec = resolve_fallback_voice_spec("M1", "M1", ["M1", "F1"])
|
||||||
# Should return a valid voice spec or None
|
|
||||||
if spec is not None:
|
if spec is not None:
|
||||||
assert hasattr(spec, "voice_id") or isinstance(spec, str)
|
assert hasattr(spec, "voice_id") or isinstance(spec, str)
|
||||||
|
|
||||||
def test_spec_to_voice_ids(self):
|
def test_spec_to_voice_ids(self):
|
||||||
|
from abogen.domain.voice_resolution import spec_to_voice_ids
|
||||||
ids = spec_to_voice_ids("M1")
|
ids = spec_to_voice_ids("M1")
|
||||||
assert isinstance(ids, set)
|
assert isinstance(ids, set)
|
||||||
assert len(ids) >= 0 # may be empty if M1 not in kokoro voices
|
|
||||||
|
|
||||||
def test_resolve_fallback_with_empty_cache(self):
|
def test_resolve_fallback_with_empty_cache(self):
|
||||||
|
from abogen.domain.voice_resolution import resolve_fallback_voice_spec
|
||||||
spec = resolve_fallback_voice_spec("M1", "M1", [])
|
spec = resolve_fallback_voice_spec("M1", "M1", [])
|
||||||
# Should handle empty cache gracefully
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Intro/Outro ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestIntroOutro:
|
class TestIntroOutro:
|
||||||
"""Verify intro/outro resolution with various metadata states."""
|
"""Verify intro/outro resolution with various metadata states."""
|
||||||
|
|
||||||
def test_resolve_intro_with_metadata(self):
|
def test_resolve_intro_with_metadata(self):
|
||||||
metadata = {
|
from abogen.domain.intro_outro import resolve_intro
|
||||||
"title": "Test Book",
|
metadata = {"title": "Test Book", "author": "Test Author"}
|
||||||
"author": "Test Author",
|
spec = resolve_intro(metadata, "test.txt", True, "M1", "M1", ["M1"])
|
||||||
}
|
|
||||||
spec = resolve_intro(
|
|
||||||
metadata, "test.txt", True,
|
|
||||||
"M1", "M1", ["M1"],
|
|
||||||
)
|
|
||||||
assert spec is not None
|
assert spec is not None
|
||||||
assert spec.text # should have some text
|
assert spec.text
|
||||||
|
|
||||||
def test_resolve_intro_disabled(self):
|
def test_resolve_intro_disabled(self):
|
||||||
spec = resolve_intro(
|
from abogen.domain.intro_outro import resolve_intro
|
||||||
{}, "test.txt", False,
|
spec = resolve_intro({}, "test.txt", False, "M1", "M1", ["M1"])
|
||||||
"M1", "M1", ["M1"],
|
|
||||||
)
|
|
||||||
assert not spec.enabled
|
assert not spec.enabled
|
||||||
|
|
||||||
def test_resolve_intro_no_metadata(self):
|
def test_resolve_intro_no_metadata(self):
|
||||||
spec = resolve_intro(
|
from abogen.domain.intro_outro import resolve_intro
|
||||||
{}, "test.txt", True,
|
spec = resolve_intro({}, "test.txt", True, "M1", "M1", ["M1"])
|
||||||
"M1", "M1", ["M1"],
|
|
||||||
)
|
|
||||||
# May or may not find text, but should not crash
|
|
||||||
assert spec is not None
|
assert spec is not None
|
||||||
|
|
||||||
def test_resolve_outro_with_metadata(self):
|
def test_resolve_outro_with_metadata(self):
|
||||||
|
from abogen.domain.intro_outro import resolve_outro
|
||||||
metadata = {"title": "Test Book"}
|
metadata = {"title": "Test Book"}
|
||||||
spec = resolve_outro(
|
spec = resolve_outro(metadata, "test.txt", True, "M1", "M1", ["M1"])
|
||||||
metadata, "test.txt", True,
|
|
||||||
"M1", "M1", ["M1"],
|
|
||||||
)
|
|
||||||
assert spec is not None
|
assert spec is not None
|
||||||
assert spec.text
|
assert spec.text
|
||||||
|
|
||||||
def test_resolve_outro_disabled(self):
|
def test_resolve_outro_disabled(self):
|
||||||
spec = resolve_outro(
|
from abogen.domain.intro_outro import resolve_outro
|
||||||
{}, "test.txt", False,
|
spec = resolve_outro({}, "test.txt", False, "M1", "M1", ["M1"])
|
||||||
"M1", "M1", ["M1"],
|
|
||||||
)
|
|
||||||
assert not spec.enabled
|
assert not spec.enabled
|
||||||
|
|
||||||
|
|
||||||
# ─── Output Paths ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestOutputPaths:
|
class TestOutputPaths:
|
||||||
"""Verify output path resolution produces valid paths."""
|
"""Verify output path resolution produces valid paths."""
|
||||||
|
|
||||||
def test_resolve_unique_path(self, tmp_path):
|
def test_resolve_unique_path(self, tmp_path):
|
||||||
# Create a file to force collision
|
from abogen.domain.output_paths import resolve_unique_path
|
||||||
(tmp_path / "test.txt").touch()
|
(tmp_path / "test.txt").touch()
|
||||||
result = resolve_unique_path(
|
result = resolve_unique_path(
|
||||||
str(tmp_path), "test", "txt",
|
str(tmp_path), "test", "txt",
|
||||||
@@ -225,20 +463,21 @@ class TestOutputPaths:
|
|||||||
)
|
)
|
||||||
assert result
|
assert result
|
||||||
assert "test" in result
|
assert "test" in result
|
||||||
# Should have a suffix since "test.txt" already exists
|
|
||||||
assert result != str(tmp_path / "test")
|
|
||||||
|
|
||||||
def test_resolve_unique_path_no_collision(self, tmp_path):
|
def test_resolve_unique_path_no_collision(self, tmp_path):
|
||||||
|
from abogen.domain.output_paths import resolve_unique_path
|
||||||
result = resolve_unique_path(str(tmp_path), "unique_name", "txt")
|
result = resolve_unique_path(str(tmp_path), "unique_name", "txt")
|
||||||
assert result
|
assert result
|
||||||
assert "unique_name" in result
|
assert "unique_name" in result
|
||||||
|
|
||||||
def test_sanitize_output_stem(self):
|
def test_sanitize_output_stem(self):
|
||||||
|
from abogen.domain.output_paths import sanitize_output_stem
|
||||||
stem = sanitize_output_stem("My Book Title")
|
stem = sanitize_output_stem("My Book Title")
|
||||||
assert isinstance(stem, str)
|
assert isinstance(stem, str)
|
||||||
assert len(stem) > 0
|
assert len(stem) > 0
|
||||||
|
|
||||||
def test_resolve_output_directory(self, tmp_path):
|
def test_resolve_output_directory(self, tmp_path):
|
||||||
|
from abogen.domain.output_paths import resolve_output_directory
|
||||||
result = resolve_output_directory(
|
result = resolve_output_directory(
|
||||||
save_mode="Save next to input file",
|
save_mode="Save next to input file",
|
||||||
stored_path=tmp_path / "test.txt",
|
stored_path=tmp_path / "test.txt",
|
||||||
@@ -251,12 +490,11 @@ class TestOutputPaths:
|
|||||||
assert isinstance(result, Path)
|
assert isinstance(result, Path)
|
||||||
|
|
||||||
|
|
||||||
# ─── Subtitle Generation ───────────────────────────────────────────
|
|
||||||
|
|
||||||
class TestSubtitleGeneration:
|
class TestSubtitleGeneration:
|
||||||
"""Verify subtitle token processing works correctly."""
|
"""Verify subtitle token processing works correctly."""
|
||||||
|
|
||||||
def test_process_empty_tokens(self):
|
def test_process_empty_tokens(self):
|
||||||
|
from abogen.domain.subtitle_generation import process_subtitle_tokens
|
||||||
entries = []
|
entries = []
|
||||||
process_subtitle_tokens(
|
process_subtitle_tokens(
|
||||||
[], entries, 5, "Sentence", "a",
|
[], entries, 5, "Sentence", "a",
|
||||||
@@ -266,6 +504,7 @@ class TestSubtitleGeneration:
|
|||||||
assert entries == []
|
assert entries == []
|
||||||
|
|
||||||
def test_process_sentence_mode(self):
|
def test_process_sentence_mode(self):
|
||||||
|
from abogen.domain.subtitle_generation import process_subtitle_tokens
|
||||||
tokens = [
|
tokens = [
|
||||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||||
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "."},
|
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "."},
|
||||||
@@ -276,13 +515,13 @@ class TestSubtitleGeneration:
|
|||||||
use_spacy_segmentation=False,
|
use_spacy_segmentation=False,
|
||||||
fallback_end_time=2.0,
|
fallback_end_time=2.0,
|
||||||
)
|
)
|
||||||
# Should produce at least one entry
|
|
||||||
assert len(entries) >= 1
|
assert len(entries) >= 1
|
||||||
start, end, text = entries[0]
|
start, end, text = entries[0]
|
||||||
assert start < end
|
assert start < end
|
||||||
assert isinstance(text, str)
|
assert isinstance(text, str)
|
||||||
|
|
||||||
def test_process_line_mode(self):
|
def test_process_line_mode(self):
|
||||||
|
from abogen.domain.subtitle_generation import process_subtitle_tokens
|
||||||
tokens = [
|
tokens = [
|
||||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||||
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "\n"},
|
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "\n"},
|
||||||
@@ -295,34 +534,27 @@ class TestSubtitleGeneration:
|
|||||||
use_spacy_segmentation=False,
|
use_spacy_segmentation=False,
|
||||||
fallback_end_time=3.0,
|
fallback_end_time=3.0,
|
||||||
)
|
)
|
||||||
# Line mode should produce entries split by newlines
|
|
||||||
assert len(entries) >= 1
|
assert len(entries) >= 1
|
||||||
|
|
||||||
|
|
||||||
# ─── Feature Parity Regression ─────────────────────────────────────
|
|
||||||
|
|
||||||
class TestFeatureParity:
|
class TestFeatureParity:
|
||||||
"""Regression tests for features that must work in both UIs."""
|
"""Regression tests for features that must work in both UIs."""
|
||||||
|
|
||||||
def test_chapter_title_formatting(self):
|
def test_chapter_title_formatting(self):
|
||||||
"""Chapter titles should be formatted consistently."""
|
from abogen.domain.chapter_titles import format_spoken_chapter_title
|
||||||
title1 = format_spoken_chapter_title("Chapter 1", 1, apply_prefix=True)
|
title1 = format_spoken_chapter_title("Chapter 1", 1, apply_prefix=True)
|
||||||
title2 = format_spoken_chapter_title("Introduction", 1, apply_prefix=True)
|
title2 = format_spoken_chapter_title("Introduction", 1, apply_prefix=True)
|
||||||
assert isinstance(title1, str)
|
assert isinstance(title1, str)
|
||||||
assert isinstance(title2, str)
|
assert isinstance(title2, str)
|
||||||
|
|
||||||
def test_chapter_title_no_auto_prefix(self):
|
def test_chapter_title_no_auto_prefix(self):
|
||||||
|
from abogen.domain.chapter_titles import format_spoken_chapter_title
|
||||||
title = format_spoken_chapter_title("My Custom Title", 1, apply_prefix=False)
|
title = format_spoken_chapter_title("My Custom Title", 1, apply_prefix=False)
|
||||||
assert "My Custom Title" in title
|
assert "My Custom Title" in title
|
||||||
|
|
||||||
def test_m4b_forces_merge(self):
|
def test_m4b_forces_merge(self):
|
||||||
"""m4b format should force merge_chapters_at_end=True.
|
|
||||||
This is a business rule that must be enforced."""
|
|
||||||
# This is tested implicitly — the domain doesn't enforce this,
|
|
||||||
# but both UIs should. We document the expected behavior here.
|
|
||||||
output_format = "m4b"
|
output_format = "m4b"
|
||||||
merge_chapters_at_end = False
|
merge_chapters_at_end = False
|
||||||
# The UI should set this to True for m4b
|
|
||||||
if output_format.lower() == "m4b":
|
if output_format.lower() == "m4b":
|
||||||
merge_chapters_at_end = True
|
merge_chapters_at_end = True
|
||||||
assert merge_chapters_at_end is True
|
assert merge_chapters_at_end is True
|
||||||
|
|||||||
Reference in New Issue
Block a user