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:
Artem Akymenko
2026-07-21 11:16:40 +03:00
parent 28998e1e5c
commit e1e49e8a0f
3 changed files with 910 additions and 0 deletions
+388
View File
@@ -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
+328
View File
@@ -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
+194
View File
@@ -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")