refactor: extract output path utilities to domain/output_paths.py

- Extract slugify, sanitize_output_stem, output_timestamp_token, build_output_path
- Extract apply_newline_policy, resolve_output_directory, resolve_project_layout
- _prepare_output_dir and _prepare_project_layout become thin wrappers with mkdir
- Add tests/test_output_paths.py (21 tests)
- conversion_runner.py: 1443 → 1410 lines
- All tests pass
This commit is contained in:
Artem Akymenko
2026-07-15 11:56:06 +00:00
parent 914e77de46
commit 4364276a5b
3 changed files with 355 additions and 119 deletions
+242 -64
View File
@@ -1,79 +1,257 @@
"""Tests for output path utilities.
Tests import from domain/output_paths.py (new module).
"""
from __future__ import annotations
import time
import re
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
import pytest
from abogen.webui.conversion_runner import _build_output_path, _prepare_project_layout
from abogen.webui.service import Job
# ---------------------------------------------------------------------------
# slugify
# ---------------------------------------------------------------------------
def _sample_job(tmp_path: Path) -> Job:
source = tmp_path / "sample.txt"
source.write_text("example", encoding="utf-8")
return Job(
id="job-1",
original_filename="Sample Title.txt",
stored_path=source,
language="en",
voice="af_alloy",
speed=1.0,
use_gpu=False,
subtitle_mode="Sentence",
output_format="mp3",
save_mode="Use default save location",
output_folder=tmp_path,
replace_single_newlines=False,
subtitle_format="srt",
created_at=time.time(),
)
class TestSlugify:
"""slugify converts title to filesystem-safe slug."""
def test_basic_slug(self):
from abogen.domain.output_paths import slugify
assert slugify("Hello World", 0) == "hello_world"
def test_strips_special_chars(self):
from abogen.domain.output_paths import slugify
result = slugify("Chapter 1: The Beginning!", 0)
assert result == "chapter_1_the_beginning"
def test_empty_title_uses_index(self):
from abogen.domain.output_paths import slugify
assert slugify("", 5) == "chapter_05"
def test_truncated_at_80(self):
from abogen.domain.output_paths import slugify
long_title = "a" * 100
assert len(slugify(long_title, 0)) == 80
def test_preserves_hyphens(self):
from abogen.domain.output_paths import slugify
assert slugify("mid-night", 0) == "mid-night"
def test_prepare_project_layout_uses_timestamped_folder(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
job = _sample_job(tmp_path)
monkeypatch.setattr(
"abogen.webui.conversion_runner._output_timestamp_token",
lambda: "20250101-120000",
)
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(
job, tmp_path
)
assert project_root.name.startswith(
"20250101-120000_Sample_Title"
), project_root.name
assert audio_dir == project_root
assert subtitle_dir == project_root
assert metadata_dir is None
output_path = _build_output_path(audio_dir, job.original_filename, "mp3")
assert output_path == project_root / "Sample_Title.mp3"
# ---------------------------------------------------------------------------
# sanitize_output_stem
# ---------------------------------------------------------------------------
def test_prepare_project_layout_creates_project_subdirs(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
job = _sample_job(tmp_path)
job.save_as_project = True
monkeypatch.setattr(
"abogen.webui.conversion_runner._output_timestamp_token",
lambda: "20250101-120500",
)
class TestSanitizeOutputStem:
"""sanitize_output_stem cleans filename stem."""
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(
job, tmp_path
)
def test_basic_sanitize(self):
from abogen.domain.output_paths import sanitize_output_stem
assert audio_dir == project_root / "audio"
assert subtitle_dir == project_root / "subtitles"
assert metadata_dir == project_root / "metadata"
assert audio_dir.is_dir()
assert subtitle_dir.is_dir()
assert metadata_dir is not None and metadata_dir.is_dir()
assert sanitize_output_stem("my file.mp3") == "my_file"
output_path = _build_output_path(audio_dir, job.original_filename, "wav")
assert output_path == audio_dir / "Sample_Title.wav"
def test_empty_returns_default(self):
from abogen.domain.output_paths import sanitize_output_stem
assert sanitize_output_stem("") == "output"
def test_strips_underscores(self):
from abogen.domain.output_paths import sanitize_output_stem
assert sanitize_output_stem("__test__") == "test"
# ---------------------------------------------------------------------------
# output_timestamp_token
# ---------------------------------------------------------------------------
class TestOutputTimestampToken:
"""output_timestamp_token returns timestamp string."""
def test_format(self):
from abogen.domain.output_paths import output_timestamp_token
token = output_timestamp_token()
assert re.match(r"\d{8}-\d{6}", token)
# ---------------------------------------------------------------------------
# build_output_path
# ---------------------------------------------------------------------------
class TestBuildOutputPath:
"""build_output_path builds the output file path."""
def test_basic_path(self, tmp_path):
from abogen.domain.output_paths import build_output_path
result = build_output_path(tmp_path, "test.mp3", "mp3")
assert result.suffix == ".mp3"
assert result.parent == tmp_path
def test_stem_sanitized(self, tmp_path):
from abogen.domain.output_paths import build_output_path
result = build_output_path(tmp_path, "my file.txt", "wav")
assert "my_file" in result.name
# ---------------------------------------------------------------------------
# apply_newline_policy
# ---------------------------------------------------------------------------
class TestApplyNewlinePolicy:
"""apply_newline_policy replaces single newlines in chapter text."""
def test_noop_when_disabled(self):
from abogen.domain.output_paths import apply_newline_policy
from abogen.text_extractor import ExtractedChapter
chapters = [ExtractedChapter(title="t", text="a\nb")]
apply_newline_policy(chapters, False)
assert chapters[0].text == "a\nb"
def test_replaces_single_newlines(self):
from abogen.domain.output_paths import apply_newline_policy
from abogen.text_extractor import ExtractedChapter
chapters = [ExtractedChapter(title="t", text="a\nb\nc")]
apply_newline_policy(chapters, True)
assert chapters[0].text == "a b c"
def test_preserves_double_newlines(self):
from abogen.domain.output_paths import apply_newline_policy
from abogen.text_extractor import ExtractedChapter
chapters = [ExtractedChapter(title="t", text="a\n\nb")]
apply_newline_policy(chapters, True)
assert chapters[0].text == "a\n\nb"
# ---------------------------------------------------------------------------
# resolve_output_directory
# ---------------------------------------------------------------------------
class TestResolveOutputDirectory:
"""resolve_output_directory determines output dir from save_mode."""
def test_save_to_desktop(self, tmp_path):
from abogen.domain.output_paths import resolve_output_directory
result = resolve_output_directory(
save_mode="Save to Desktop",
stored_path=Path("/input/book.epub"),
output_folder=None,
desktop_dir=tmp_path,
user_output_path=None,
user_cache_outputs=None,
)
assert result == tmp_path
def test_save_next_to_input(self, tmp_path):
from abogen.domain.output_paths import resolve_output_directory
stored = tmp_path / "book.epub"
result = resolve_output_directory(
save_mode="Save next to input file",
stored_path=stored,
output_folder=None,
desktop_dir=None,
user_output_path=None,
user_cache_outputs=None,
)
assert result == tmp_path
def test_choose_output_folder(self, tmp_path):
from abogen.domain.output_paths import resolve_output_directory
custom = tmp_path / "custom"
result = resolve_output_directory(
save_mode="Choose output folder",
stored_path=Path("/x/y.epub"),
output_folder=str(custom),
desktop_dir=None,
user_output_path=None,
user_cache_outputs=None,
)
assert result == custom
def test_use_default_save_location(self, tmp_path):
from abogen.domain.output_paths import resolve_output_directory
result = resolve_output_directory(
save_mode="Use default save location",
stored_path=Path("/x/y.epub"),
output_folder=None,
desktop_dir=None,
user_output_path=tmp_path / "default",
user_cache_outputs=None,
)
assert result == tmp_path / "default"
def test_fallback_to_cache(self, tmp_path):
from abogen.domain.output_paths import resolve_output_directory
result = resolve_output_directory(
save_mode="unknown",
stored_path=Path("/x/y.epub"),
output_folder=None,
desktop_dir=None,
user_output_path=None,
user_cache_outputs=tmp_path / "cache",
)
assert result == tmp_path / "cache"
# ---------------------------------------------------------------------------
# resolve_project_layout
# ---------------------------------------------------------------------------
class TestResolveProjectLayout:
"""resolve_project_layout computes project folder structure."""
def test_flat_layout(self, tmp_path):
from abogen.domain.output_paths import resolve_project_layout
root, audio, subs, meta = resolve_project_layout(
original_filename="book.epub",
save_as_project=False,
base_dir=tmp_path,
timestamp_fn=lambda: "20260101-000000",
sanitize_fn=lambda n, i: "book",
)
assert audio == root
assert subs == root
assert meta is None
def test_project_layout(self, tmp_path):
from abogen.domain.output_paths import resolve_project_layout
root, audio, subs, meta = resolve_project_layout(
original_filename="book.epub",
save_as_project=True,
base_dir=tmp_path,
timestamp_fn=lambda: "20260101-000000",
sanitize_fn=lambda n, i: "book",
)
assert root.name == "20260101-000000_book"
assert audio.name == "audio"
assert subs.name == "subtitles"
assert meta.name == "metadata"