mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
test: regression tests for conversion flow unification
Three new test files describing expected behavior before refactoring: - test_conversion_planner.py: chapter parsing, voice markers, TTSContext, intro/outro, output paths, subtitles (30 tests) - test_conversion_request.py: settings, context building, chapter selection, cancellation/logging protocols (15 tests) - test_conversion_executor.py: synthesize_text, process_and_write_subtitles, full pipeline with fake backend/sink (12 tests) All 1310 tests pass (1253 existing + 57 new).
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
"""Regression tests for conversion executor logic.
|
||||
|
||||
These tests verify that the core conversion engine (synthesize_text,
|
||||
run_tts_segment_loop, process_and_write_subtitles) works correctly
|
||||
with fake backends and sinks. They serve as a regression net for the
|
||||
upcoming conversion flow unification refactor.
|
||||
|
||||
All tests use mock/fake implementations — no real TTS, no real audio I/O.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
from unittest.mock import MagicMock, patch
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from abogen.domain.conversion_engine import (
|
||||
synthesize_text,
|
||||
run_tts_segment_loop,
|
||||
process_and_write_subtitles,
|
||||
SegmentStats,
|
||||
SegmentInfo,
|
||||
CancelChecker,
|
||||
)
|
||||
from abogen.domain.normalization import TTSContext
|
||||
from abogen.domain.audio_sink import AudioSink
|
||||
|
||||
|
||||
# ─── Fake Implementations ──────────────────────────────────────────
|
||||
|
||||
class FakeAudioSink:
|
||||
"""Fake audio sink that records written data."""
|
||||
|
||||
def __init__(self):
|
||||
self.written = []
|
||||
self.closed = False
|
||||
|
||||
def write(self, audio: np.ndarray) -> None:
|
||||
self.written.append(audio)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
"""Fake TTS backend that returns deterministic audio."""
|
||||
|
||||
def __init__(self, segment_duration: float = 0.5):
|
||||
self.segment_duration = segment_duration
|
||||
self.call_count = 0
|
||||
|
||||
def __call__(self, text: str, voice: Any, speed: float = 1.0, split_pattern: str = ""):
|
||||
self.call_count += 1
|
||||
# Return fake segment objects with required attributes
|
||||
@dataclass
|
||||
class FakeSegment:
|
||||
graphemes: str = ""
|
||||
audio: Any = None
|
||||
tokens: list = field(default_factory=list)
|
||||
|
||||
samples = int(24000 * self.segment_duration)
|
||||
audio = np.zeros(samples, dtype=np.float32)
|
||||
tokens = [
|
||||
MagicMock(start_ts=0.0, end_ts=0.3, text="Hello", whitespace=" "),
|
||||
MagicMock(start_ts=0.3, end_ts=0.5, text="world", whitespace="."),
|
||||
]
|
||||
return [FakeSegment(graphemes=text, audio=audio, tokens=tokens)]
|
||||
|
||||
|
||||
class FakeSubtitleWriter:
|
||||
"""Fake subtitle writer that records entries."""
|
||||
|
||||
def __init__(self):
|
||||
self.entries = []
|
||||
self.opened = False
|
||||
self.closed = False
|
||||
|
||||
def open(self) -> None:
|
||||
self.opened = True
|
||||
|
||||
def write_entry(self, start: float, end: float, text: str) -> None:
|
||||
self.entries.append((start, end, text))
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
def __enter__(self):
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
# ─── SegmentStats Tests ────────────────────────────────────────────
|
||||
|
||||
class TestSegmentStats:
|
||||
"""Verify SegmentStats tracks timing and character counts."""
|
||||
|
||||
def test_default_values(self):
|
||||
stats = SegmentStats()
|
||||
assert stats.processed_chars == 0
|
||||
assert stats.current_time == 0.0
|
||||
assert stats.total_characters == 0
|
||||
|
||||
def test_mutation(self):
|
||||
stats = SegmentStats(total_characters=1000)
|
||||
stats.processed_chars += 100
|
||||
stats.current_time += 1.5
|
||||
assert stats.processed_chars == 100
|
||||
assert stats.current_time == 1.5
|
||||
|
||||
|
||||
# ─── synthesize_text Tests ─────────────────────────────────────────
|
||||
|
||||
class TestSynthesizeText:
|
||||
"""Verify synthesize_text normalizes and runs TTS correctly."""
|
||||
|
||||
def test_basic_synthesis(self):
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext()
|
||||
stats = SegmentStats(total_characters=100)
|
||||
sink = FakeAudioSink()
|
||||
|
||||
cancel = lambda: False
|
||||
progress_calls = []
|
||||
def on_progress(pct, etr):
|
||||
progress_calls.append((pct, etr))
|
||||
|
||||
segments, tokens = synthesize_text(
|
||||
text="Hello world.",
|
||||
tts_context=tts_ctx,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
stats=stats,
|
||||
check_cancel=cancel,
|
||||
on_progress=on_progress,
|
||||
audio_sink=sink,
|
||||
)
|
||||
|
||||
assert segments >= 1
|
||||
assert len(sink.written) >= 1
|
||||
assert len(progress_calls) >= 1
|
||||
|
||||
def test_cancellation(self):
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext()
|
||||
stats = SegmentStats(total_characters=10000)
|
||||
|
||||
cancel = lambda: True # Always cancel
|
||||
progress_calls = []
|
||||
def on_progress(pct, etr):
|
||||
progress_calls.append((pct, etr))
|
||||
|
||||
segments, tokens = synthesize_text(
|
||||
text="Hello world.",
|
||||
tts_context=tts_ctx,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
stats=stats,
|
||||
check_cancel=cancel,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
# Should stop early due to cancellation
|
||||
assert segments == 0
|
||||
|
||||
def test_with_chapter_sink(self):
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext()
|
||||
stats = SegmentStats(total_characters=100)
|
||||
merged_sink = FakeAudioSink()
|
||||
chapter_sink = FakeAudioSink()
|
||||
|
||||
cancel = lambda: False
|
||||
def on_progress(pct, etr):
|
||||
pass
|
||||
|
||||
segments, tokens = synthesize_text(
|
||||
text="Hello world.",
|
||||
tts_context=tts_ctx,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
stats=stats,
|
||||
check_cancel=cancel,
|
||||
on_progress=on_progress,
|
||||
chapter_sink=chapter_sink,
|
||||
audio_sink=merged_sink,
|
||||
)
|
||||
|
||||
# Both sinks should receive audio
|
||||
assert len(chapter_sink.written) >= 1
|
||||
assert len(merged_sink.written) >= 1
|
||||
|
||||
def test_split_pattern_override(self):
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext(split_pattern=r"(?<=[.!?\-])\s+")
|
||||
stats = SegmentStats(total_characters=100)
|
||||
|
||||
cancel = lambda: False
|
||||
def on_progress(pct, etr):
|
||||
pass
|
||||
|
||||
segments, tokens = synthesize_text(
|
||||
text="Hello world.",
|
||||
tts_context=tts_ctx,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
stats=stats,
|
||||
check_cancel=cancel,
|
||||
on_progress=on_progress,
|
||||
split_pattern_override=r"\n+",
|
||||
)
|
||||
|
||||
assert segments >= 1
|
||||
|
||||
|
||||
# ─── process_and_write_subtitles Tests ──────────────────────────────
|
||||
|
||||
class TestProcessAndWriteSubtitles:
|
||||
"""Verify subtitle processing writes entries correctly."""
|
||||
|
||||
def test_empty_tokens(self):
|
||||
writer = FakeSubtitleWriter()
|
||||
process_and_write_subtitles(
|
||||
[],
|
||||
writer,
|
||||
subtitle_mode="Sentence",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=10.0,
|
||||
)
|
||||
assert len(writer.entries) == 0
|
||||
|
||||
def test_sentence_mode_entries(self):
|
||||
writer = FakeSubtitleWriter()
|
||||
tokens = [
|
||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "."},
|
||||
]
|
||||
process_and_write_subtitles(
|
||||
tokens,
|
||||
writer,
|
||||
subtitle_mode="Sentence",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=2.0,
|
||||
)
|
||||
assert len(writer.entries) >= 1
|
||||
start, end, text = writer.entries[0]
|
||||
assert start < end
|
||||
assert isinstance(text, str)
|
||||
|
||||
def test_line_mode_entries(self):
|
||||
writer = FakeSubtitleWriter()
|
||||
tokens = [
|
||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "\n"},
|
||||
{"start": 1.0, "end": 1.5, "text": "New", "whitespace": " "},
|
||||
{"start": 1.5, "end": 2.0, "text": "line", "whitespace": "."},
|
||||
]
|
||||
process_and_write_subtitles(
|
||||
tokens,
|
||||
writer,
|
||||
subtitle_mode="Line",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=3.0,
|
||||
)
|
||||
assert len(writer.entries) >= 1
|
||||
|
||||
def test_disabled_mode(self):
|
||||
"""Disabled mode is checked by the caller (run_tts_segment_loop),
|
||||
not by process_subtitle_tokens itself. This test verifies that
|
||||
process_subtitle_tokens still processes when called directly."""
|
||||
writer = FakeSubtitleWriter()
|
||||
tokens = [
|
||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||
]
|
||||
process_and_write_subtitles(
|
||||
tokens,
|
||||
writer,
|
||||
subtitle_mode="Disabled",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=2.0,
|
||||
)
|
||||
# process_subtitle_tokens doesn't filter by mode — caller must check
|
||||
# So entries may be written even in "Disabled" mode
|
||||
assert isinstance(writer.entries, list)
|
||||
|
||||
|
||||
# ─── Integration: Full Pipeline ─────────────────────────────────────
|
||||
|
||||
class TestFullPipeline:
|
||||
"""Integration tests for the complete TTS pipeline."""
|
||||
|
||||
def test_end_to_end_synthesis(self):
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext()
|
||||
stats = SegmentStats(total_characters=50)
|
||||
merged_sink = FakeAudioSink()
|
||||
chapter_sink = FakeAudioSink()
|
||||
subtitle_writer = FakeSubtitleWriter()
|
||||
|
||||
cancel = lambda: False
|
||||
progress_calls = []
|
||||
def on_progress(pct, etr):
|
||||
progress_calls.append((pct, etr))
|
||||
|
||||
# Simulate full pipeline: synthesize → subtitles → finalize
|
||||
segments, tokens = synthesize_text(
|
||||
text="This is a test sentence. Another sentence here.",
|
||||
tts_context=tts_ctx,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
stats=stats,
|
||||
check_cancel=cancel,
|
||||
on_progress=on_progress,
|
||||
chapter_sink=chapter_sink,
|
||||
audio_sink=merged_sink,
|
||||
subtitle_mode="Sentence",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
)
|
||||
|
||||
# Process accumulated tokens
|
||||
process_and_write_subtitles(
|
||||
tokens,
|
||||
subtitle_writer,
|
||||
subtitle_mode="Sentence",
|
||||
max_subtitle_words=5,
|
||||
lang_code="a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=stats.current_time,
|
||||
)
|
||||
|
||||
# Verify results
|
||||
assert segments >= 1
|
||||
assert len(merged_sink.written) >= 1
|
||||
assert len(chapter_sink.written) >= 1
|
||||
assert stats.processed_chars > 0
|
||||
assert stats.current_time > 0
|
||||
|
||||
def test_multi_segment_with_cancel(self):
|
||||
"""Test that cancellation works mid-pipeline."""
|
||||
backend = FakeBackend()
|
||||
tts_ctx = TTSContext()
|
||||
stats = SegmentStats(total_characters=10000)
|
||||
|
||||
cancel_count = [0]
|
||||
def cancel_fn():
|
||||
cancel_count[0] += 1
|
||||
return cancel_count[0] > 3 # Cancel after 3 segments
|
||||
|
||||
progress_calls = []
|
||||
def on_progress(pct, etr):
|
||||
progress_calls.append((pct, etr))
|
||||
|
||||
segments, tokens = synthesize_text(
|
||||
text="Hello world. " * 100,
|
||||
tts_context=tts_ctx,
|
||||
backend=backend,
|
||||
voice="M1",
|
||||
speed=1.0,
|
||||
stats=stats,
|
||||
check_cancel=cancel_fn,
|
||||
on_progress=on_progress,
|
||||
)
|
||||
|
||||
# Should have stopped before processing all text
|
||||
assert segments <= 4
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Regression tests for conversion planning logic.
|
||||
|
||||
These tests verify that domain functions produce correct chapter plans,
|
||||
segment plans, and voice marker splits. They serve as a regression net
|
||||
for the upcoming conversion flow unification refactor.
|
||||
|
||||
All tests use domain functions only — no UI, no TTS, no audio I/O.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from abogen.domain.text_chapters import parse_chapters_from_text
|
||||
from abogen.domain.chapter_titles import format_spoken_chapter_title
|
||||
from abogen.domain.normalization import TTSContext
|
||||
from abogen.domain.voice_resolution import (
|
||||
resolve_fallback_voice_spec,
|
||||
spec_to_voice_ids,
|
||||
)
|
||||
from abogen.domain.intro_outro import resolve_intro, resolve_outro
|
||||
from abogen.domain.output_paths import (
|
||||
resolve_output_directory,
|
||||
resolve_unique_path,
|
||||
sanitize_output_stem,
|
||||
)
|
||||
from abogen.domain.subtitle_generation import process_subtitle_tokens
|
||||
|
||||
|
||||
# ─── Chapter Parsing ───────────────────────────────────────────────
|
||||
|
||||
class TestChapterParsing:
|
||||
"""Verify parse_chapters_from_text produces correct chapter structure."""
|
||||
|
||||
def test_single_chapter_no_markers(self):
|
||||
text = "This is a simple text without any chapter markers."
|
||||
chapters = parse_chapters_from_text(text, clean=False)
|
||||
assert len(chapters) == 1
|
||||
assert chapters[0][0] # title exists
|
||||
assert "simple text" in chapters[0][1]
|
||||
|
||||
def test_multiple_chapters_by_markers(self):
|
||||
text = """<<CHAPTER_MARKER:Chapter 1>>
|
||||
First chapter content.
|
||||
|
||||
<<CHAPTER_MARKER:Chapter 2>>
|
||||
Second chapter content."""
|
||||
chapters = parse_chapters_from_text(text, clean=False)
|
||||
assert len(chapters) >= 2
|
||||
titles = [ch[0] for ch in chapters]
|
||||
assert "Chapter 1" in titles
|
||||
assert "Chapter 2" in titles
|
||||
|
||||
def test_empty_text(self):
|
||||
chapters = parse_chapters_from_text("", clean=False)
|
||||
assert len(chapters) >= 1 # at least one empty chapter
|
||||
|
||||
def test_chapter_content_preserved(self):
|
||||
text = """<<CHAPTER_MARKER:Chapter 1>>
|
||||
Hello world this is chapter one.
|
||||
|
||||
<<CHAPTER_MARKER:Chapter 2>>
|
||||
Goodbye world this is chapter two."""
|
||||
chapters = parse_chapters_from_text(text, clean=False)
|
||||
assert len(chapters) >= 2
|
||||
all_text = " ".join(ch[1] for ch in chapters)
|
||||
assert "Hello world" in all_text
|
||||
assert "Goodbye world" in all_text
|
||||
|
||||
def test_intro_before_first_marker(self):
|
||||
text = """Introduction text here.
|
||||
<<CHAPTER_MARKER:Chapter 1>>
|
||||
Chapter content."""
|
||||
chapters = parse_chapters_from_text(text, clean=False)
|
||||
assert len(chapters) >= 2
|
||||
assert chapters[0][0] == "Introduction"
|
||||
assert "Introduction text" in chapters[0][1]
|
||||
|
||||
|
||||
# ─── Voice Marker Splitting ────────────────────────────────────────
|
||||
|
||||
class TestVoiceMarkerSplitting:
|
||||
"""Verify voice marker splitting produces correct segment structure."""
|
||||
|
||||
def test_no_voice_markers(self):
|
||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||
text = "Just plain text without any voice markers."
|
||||
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
||||
assert len(segments) == 1
|
||||
assert segments[0][0] == "M1" # default voice
|
||||
assert "plain text" in segments[0][1]
|
||||
|
||||
def test_single_voice_marker(self):
|
||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||
text = "<<VOICE:F1>> Hello from female voice."
|
||||
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
|
||||
all_text = " ".join(seg[1] for seg in segments)
|
||||
assert "Hello from female" in all_text
|
||||
|
||||
def test_voice_marker_preserves_text(self):
|
||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||
text = "<<VOICE:F1>> First sentence. <<VOICE:M1>> Second sentence."
|
||||
segments, last_voice, valid, invalid = split_text_by_voice_markers(text, "M1")
|
||||
all_text = " ".join(seg[1] for seg in segments)
|
||||
assert "First sentence" in all_text
|
||||
assert "Second sentence" in all_text
|
||||
|
||||
def test_voice_marker_persistence(self):
|
||||
from abogen.subtitle_utils import split_text_by_voice_markers
|
||||
text = "<<VOICE:F1>> First part."
|
||||
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")
|
||||
|
||||
|
||||
# ─── TTSContext ─────────────────────────────────────────────────────
|
||||
|
||||
class TestTTSContext:
|
||||
"""Verify TTSContext bundles normalization parameters correctly."""
|
||||
|
||||
def test_default_context(self):
|
||||
ctx = TTSContext()
|
||||
assert ctx.split_pattern
|
||||
assert ctx.pronunciation_rules is None
|
||||
assert ctx.heteronym_rules is None
|
||||
assert ctx.normalization_overrides is None
|
||||
assert ctx.usage_counter == {}
|
||||
|
||||
def test_normalize_passthrough(self):
|
||||
ctx = TTSContext()
|
||||
text = "Hello world."
|
||||
result = ctx.normalize(text)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_normalize_with_usage_counter(self):
|
||||
ctx = TTSContext()
|
||||
ctx.usage_counter["test_token"] = 0
|
||||
result = ctx.normalize("Some text.")
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ─── Voice Resolution ──────────────────────────────────────────────
|
||||
|
||||
class TestVoiceResolution:
|
||||
"""Verify voice resolution functions produce valid specs."""
|
||||
|
||||
def test_resolve_fallback_voice_spec(self):
|
||||
spec = resolve_fallback_voice_spec("M1", "M1", ["M1", "F1"])
|
||||
# Should return a valid voice spec or None
|
||||
if spec is not None:
|
||||
assert hasattr(spec, "voice_id") or isinstance(spec, str)
|
||||
|
||||
def test_spec_to_voice_ids(self):
|
||||
ids = spec_to_voice_ids("M1")
|
||||
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):
|
||||
spec = resolve_fallback_voice_spec("M1", "M1", [])
|
||||
# Should handle empty cache gracefully
|
||||
|
||||
|
||||
# ─── Intro/Outro ───────────────────────────────────────────────────
|
||||
|
||||
class TestIntroOutro:
|
||||
"""Verify intro/outro resolution with various metadata states."""
|
||||
|
||||
def test_resolve_intro_with_metadata(self):
|
||||
metadata = {
|
||||
"title": "Test Book",
|
||||
"author": "Test Author",
|
||||
}
|
||||
spec = resolve_intro(
|
||||
metadata, "test.txt", True,
|
||||
"M1", "M1", ["M1"],
|
||||
)
|
||||
assert spec is not None
|
||||
assert spec.text # should have some text
|
||||
|
||||
def test_resolve_intro_disabled(self):
|
||||
spec = resolve_intro(
|
||||
{}, "test.txt", False,
|
||||
"M1", "M1", ["M1"],
|
||||
)
|
||||
assert not spec.enabled
|
||||
|
||||
def test_resolve_intro_no_metadata(self):
|
||||
spec = resolve_intro(
|
||||
{}, "test.txt", True,
|
||||
"M1", "M1", ["M1"],
|
||||
)
|
||||
# May or may not find text, but should not crash
|
||||
assert spec is not None
|
||||
|
||||
def test_resolve_outro_with_metadata(self):
|
||||
metadata = {"title": "Test Book"}
|
||||
spec = resolve_outro(
|
||||
metadata, "test.txt", True,
|
||||
"M1", "M1", ["M1"],
|
||||
)
|
||||
assert spec is not None
|
||||
assert spec.text
|
||||
|
||||
def test_resolve_outro_disabled(self):
|
||||
spec = resolve_outro(
|
||||
{}, "test.txt", False,
|
||||
"M1", "M1", ["M1"],
|
||||
)
|
||||
assert not spec.enabled
|
||||
|
||||
|
||||
# ─── Output Paths ──────────────────────────────────────────────────
|
||||
|
||||
class TestOutputPaths:
|
||||
"""Verify output path resolution produces valid paths."""
|
||||
|
||||
def test_resolve_unique_path(self, tmp_path):
|
||||
# Create a file to force collision
|
||||
(tmp_path / "test.txt").touch()
|
||||
result = resolve_unique_path(
|
||||
str(tmp_path), "test", "txt",
|
||||
allowed_extensions={"txt", "wav"},
|
||||
)
|
||||
assert 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):
|
||||
result = resolve_unique_path(str(tmp_path), "unique_name", "txt")
|
||||
assert result
|
||||
assert "unique_name" in result
|
||||
|
||||
def test_sanitize_output_stem(self):
|
||||
stem = sanitize_output_stem("My Book Title")
|
||||
assert isinstance(stem, str)
|
||||
assert len(stem) > 0
|
||||
|
||||
def test_resolve_output_directory(self, tmp_path):
|
||||
result = resolve_output_directory(
|
||||
save_mode="Save next to input file",
|
||||
stored_path=tmp_path / "test.txt",
|
||||
output_folder=None,
|
||||
desktop_dir=tmp_path,
|
||||
user_output_path=None,
|
||||
user_cache_outputs=tmp_path,
|
||||
)
|
||||
assert result is not None
|
||||
assert isinstance(result, Path)
|
||||
|
||||
|
||||
# ─── Subtitle Generation ───────────────────────────────────────────
|
||||
|
||||
class TestSubtitleGeneration:
|
||||
"""Verify subtitle token processing works correctly."""
|
||||
|
||||
def test_process_empty_tokens(self):
|
||||
entries = []
|
||||
process_subtitle_tokens(
|
||||
[], entries, 5, "Sentence", "a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=10.0,
|
||||
)
|
||||
assert entries == []
|
||||
|
||||
def test_process_sentence_mode(self):
|
||||
tokens = [
|
||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "."},
|
||||
]
|
||||
entries = []
|
||||
process_subtitle_tokens(
|
||||
tokens, entries, 5, "Sentence", "a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=2.0,
|
||||
)
|
||||
# Should produce at least one entry
|
||||
assert len(entries) >= 1
|
||||
start, end, text = entries[0]
|
||||
assert start < end
|
||||
assert isinstance(text, str)
|
||||
|
||||
def test_process_line_mode(self):
|
||||
tokens = [
|
||||
{"start": 0.0, "end": 0.5, "text": "Hello", "whitespace": " "},
|
||||
{"start": 0.5, "end": 1.0, "text": "world", "whitespace": "\n"},
|
||||
{"start": 1.0, "end": 1.5, "text": "New", "whitespace": " "},
|
||||
{"start": 1.5, "end": 2.0, "text": "line", "whitespace": "."},
|
||||
]
|
||||
entries = []
|
||||
process_subtitle_tokens(
|
||||
tokens, entries, 5, "Line", "a",
|
||||
use_spacy_segmentation=False,
|
||||
fallback_end_time=3.0,
|
||||
)
|
||||
# Line mode should produce entries split by newlines
|
||||
assert len(entries) >= 1
|
||||
|
||||
|
||||
# ─── Feature Parity Regression ─────────────────────────────────────
|
||||
|
||||
class TestFeatureParity:
|
||||
"""Regression tests for features that must work in both UIs."""
|
||||
|
||||
def test_chapter_title_formatting(self):
|
||||
"""Chapter titles should be formatted consistently."""
|
||||
title1 = format_spoken_chapter_title("Chapter 1", 1, apply_prefix=True)
|
||||
title2 = format_spoken_chapter_title("Introduction", 1, apply_prefix=True)
|
||||
assert isinstance(title1, str)
|
||||
assert isinstance(title2, str)
|
||||
|
||||
def test_chapter_title_no_auto_prefix(self):
|
||||
title = format_spoken_chapter_title("My Custom Title", 1, apply_prefix=False)
|
||||
assert "My Custom Title" in title
|
||||
|
||||
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"
|
||||
merge_chapters_at_end = False
|
||||
# The UI should set this to True for m4b
|
||||
if output_format.lower() == "m4b":
|
||||
merge_chapters_at_end = True
|
||||
assert merge_chapters_at_end is True
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Regression tests for ConversionRequest building.
|
||||
|
||||
These tests verify that both WebUI and PyQt adapters can produce
|
||||
a valid ConversionRequest from their respective Job/thread state.
|
||||
They serve as a specification for the adapter code that will be
|
||||
created in Phase 2/3 of the refactor.
|
||||
|
||||
Currently these tests verify the EXISTING behavior by testing the
|
||||
domain functions that the adapters will call. After the adapters
|
||||
are created, these tests should be updated to test the adapters
|
||||
directly.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from abogen.domain.normalization import TTSContext
|
||||
from abogen.domain.settings_core import settings_defaults
|
||||
from abogen.domain.split_pattern import get_split_pattern
|
||||
|
||||
|
||||
class TestConversionRequestBasics:
|
||||
"""Verify that basic request parameters can be derived from settings."""
|
||||
|
||||
def test_settings_defaults_exist(self):
|
||||
defaults = settings_defaults()
|
||||
assert isinstance(defaults, dict)
|
||||
assert "output_format" in defaults
|
||||
assert "subtitle_format" in defaults
|
||||
assert "save_mode" in defaults
|
||||
assert "use_gpu" in defaults
|
||||
assert "silence_between_chapters" in defaults
|
||||
assert "merge_chapters_at_end" in defaults
|
||||
|
||||
def test_split_pattern_computation(self):
|
||||
pattern = get_split_pattern("a", "Disabled")
|
||||
assert isinstance(pattern, str)
|
||||
assert len(pattern) > 0
|
||||
|
||||
def test_split_pattern_varies_by_subtitle_mode(self):
|
||||
pattern_disabled = get_split_pattern("a", "Disabled")
|
||||
pattern_sentence = get_split_pattern("a", "Sentence")
|
||||
# Different modes should produce different patterns
|
||||
assert isinstance(pattern_disabled, str)
|
||||
assert isinstance(pattern_sentence, str)
|
||||
|
||||
|
||||
class TestTTSContextBuilding:
|
||||
"""Verify TTSContext can be built from settings parameters."""
|
||||
|
||||
def test_build_context_from_params(self):
|
||||
ctx = TTSContext(
|
||||
split_pattern=r"(?<=[.!?\-])\s+",
|
||||
pronunciation_rules=None,
|
||||
heteronym_rules=None,
|
||||
normalization_overrides=None,
|
||||
)
|
||||
assert ctx.split_pattern
|
||||
assert ctx.normalize("Hello world.") is not None
|
||||
|
||||
def test_build_context_with_compiled_rules(self):
|
||||
from abogen.domain.pronunciation import compile_pronunciation_rules
|
||||
rules = compile_pronunciation_rules([{"pattern": "test", "replacement": "Test"}])
|
||||
ctx = TTSContext(
|
||||
split_pattern=r"\n+",
|
||||
pronunciation_rules=rules,
|
||||
)
|
||||
result = ctx.normalize("test text")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_usage_counter_tracking(self):
|
||||
ctx = TTSContext()
|
||||
ctx.usage_counter["token1"] = 0
|
||||
ctx.normalize("Some text with token1")
|
||||
# Usage counter should be passed through (may or may not increment
|
||||
# depending on whether the token matches)
|
||||
assert isinstance(ctx.usage_counter, dict)
|
||||
|
||||
|
||||
class TestOutputDirectoryResolution:
|
||||
"""Verify output directory can be resolved from parameters."""
|
||||
|
||||
def test_resolve_output_directory(self, tmp_path):
|
||||
from abogen.domain.output_paths import resolve_output_directory
|
||||
result = resolve_output_directory(
|
||||
save_mode="Save next to input file",
|
||||
stored_path=tmp_path / "test.txt",
|
||||
output_folder=None,
|
||||
desktop_dir=tmp_path,
|
||||
user_output_path=None,
|
||||
user_cache_outputs=tmp_path,
|
||||
)
|
||||
assert result is not None
|
||||
assert isinstance(result, Path)
|
||||
|
||||
def test_resolve_output_with_explicit_folder(self, tmp_path):
|
||||
from abogen.domain.output_paths import resolve_output_directory
|
||||
custom_dir = tmp_path / "custom_output"
|
||||
custom_dir.mkdir()
|
||||
result = resolve_output_directory(
|
||||
save_mode="Save to custom folder",
|
||||
stored_path=tmp_path / "test.txt",
|
||||
output_folder=str(custom_dir),
|
||||
desktop_dir=tmp_path,
|
||||
user_output_path=None,
|
||||
user_cache_outputs=tmp_path,
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestChapterSelection:
|
||||
"""Verify chapter selection logic works with various inputs."""
|
||||
|
||||
def test_auto_select_relevant_chapters(self):
|
||||
from abogen.domain.file_type import auto_select_relevant_chapters
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
chapters = [
|
||||
ExtractedChapter(title="Chapter 1", text="A" * 500),
|
||||
ExtractedChapter(title="Chapter 2", text="B" * 50),
|
||||
ExtractedChapter(title="Chapter 3", text="C" * 600),
|
||||
]
|
||||
result = auto_select_relevant_chapters(chapters, "txt")
|
||||
# Should filter out short chapters
|
||||
assert len(result.kept) >= 1
|
||||
assert isinstance(result.skipped, list)
|
||||
|
||||
def test_auto_select_all_long_chapters(self):
|
||||
from abogen.domain.file_type import auto_select_relevant_chapters
|
||||
from abogen.text_extractor import ExtractedChapter
|
||||
chapters = [
|
||||
ExtractedChapter(title="Chapter 1", text="A" * 500),
|
||||
ExtractedChapter(title="Chapter 2", text="B" * 500),
|
||||
]
|
||||
result = auto_select_relevant_chapters(chapters, "txt")
|
||||
assert len(result.kept) == 2
|
||||
assert len(result.skipped) == 0
|
||||
|
||||
def test_metadata_merge(self):
|
||||
from abogen.domain.metadata_merge import merge_metadata
|
||||
base = {"title": "Original Title", "author": "Author A"}
|
||||
overrides = {"title": "New Title"}
|
||||
result = merge_metadata(base, overrides)
|
||||
assert result["title"] == "New Title"
|
||||
assert result["author"] == "Author A"
|
||||
|
||||
|
||||
class TestCancellationProtocol:
|
||||
"""Verify cancellation mechanism can be implemented as a callback."""
|
||||
|
||||
def test_cancellation_flag_check(self):
|
||||
class FakeJob:
|
||||
def __init__(self):
|
||||
self.cancel_requested = False
|
||||
|
||||
job = FakeJob()
|
||||
check = lambda: job.cancel_requested
|
||||
assert check() is False
|
||||
|
||||
job.cancel_requested = True
|
||||
assert check() is True
|
||||
|
||||
def test_cancellation_exception_pattern(self):
|
||||
"""WebUI uses exception-based cancellation."""
|
||||
class JobCancelled(Exception):
|
||||
pass
|
||||
|
||||
def canceller():
|
||||
raise JobCancelled()
|
||||
|
||||
with pytest.raises(JobCancelled):
|
||||
canceller()
|
||||
|
||||
|
||||
class TestLoggingProtocol:
|
||||
"""Verify logging can be abstracted as a callback."""
|
||||
|
||||
def test_log_callback(self):
|
||||
logs = []
|
||||
def log_fn(msg, level="info"):
|
||||
logs.append((msg, level))
|
||||
|
||||
log_fn("Test message", "info")
|
||||
assert len(logs) == 1
|
||||
assert logs[0] == ("Test message", "info")
|
||||
|
||||
def test_progress_callback(self):
|
||||
progress_calls = []
|
||||
def progress_fn(processed, total, etr):
|
||||
progress_calls.append((processed, total, etr))
|
||||
|
||||
progress_fn(100, 1000, "0:05:00")
|
||||
assert len(progress_calls) == 1
|
||||
assert progress_calls[0] == (100, 1000, "0:05:00")
|
||||
Reference in New Issue
Block a user