Reformat using black

This commit is contained in:
Deniz Şafak
2026-01-09 01:36:14 +03:00
parent 3c800df450
commit 5ae153f841
48 changed files with 1530 additions and 894 deletions
+3 -1
View File
@@ -10,7 +10,9 @@ import sys
from types import ModuleType
def _soundfile_write_stub(file_obj, data, samplerate, format="WAV", **_kwargs): # pragma: no cover - stub
def _soundfile_write_stub(
file_obj, data, samplerate, format="WAV", **_kwargs
): # pragma: no cover - stub
"""Minimal stand-in for soundfile.write used in tests.
The real library streams waveform data to disk. Our tests don't exercise
+4 -1
View File
@@ -2,7 +2,10 @@ from __future__ import annotations
import json
from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
from abogen.integrations.audiobookshelf import (
AudiobookshelfClient,
AudiobookshelfConfig,
)
def test_upload_fields_include_series_sequence(tmp_path):
+14 -9
View File
@@ -6,7 +6,7 @@ import time
from PyQt6.QtWidgets import QApplication
# Ensure we can import the module
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from abogen.book_handler import HandlerDialog
from ebooklib import epub
@@ -14,6 +14,7 @@ from ebooklib import epub
# We need a QApplication instance for QWriter/QDialog
app = QApplication(sys.argv)
class TestBookHandlerRegression(unittest.TestCase):
def setUp(self):
@@ -48,26 +49,29 @@ class TestBookHandlerRegression(unittest.TestCase):
# HandlerDialog starts processing in a background thread in __init__
# We assume headless environment, so we won't show it.
# But we need to wait for the thread to finish.
dialog = HandlerDialog(self.sample_epub_path)
# Wait for thread to finish
# The dialog emits no signal publicly, but we can check internal state or thread
start_time = time.time()
while time.time() - start_time < 5:
# HandlerDialog logic:
# _loader_thread.finished connect to _on_load_finished
# _on_load_finished populates content_texts and content_lengths
# We can check if content_texts is populated
if dialog.content_texts:
break
app.processEvents() # Process Qt events to let thread signals propagate
app.processEvents() # Process Qt events to let thread signals propagate
time.sleep(0.1)
self.assertTrue(len(dialog.content_texts) > 0, "HandlerDialog failed to process content in time")
self.assertTrue(
len(dialog.content_texts) > 0,
"HandlerDialog failed to process content in time",
)
# Validate content similar to what we expect
# intro.xhtml should be there
found_intro = False
@@ -80,5 +84,6 @@ class TestBookHandlerRegression(unittest.TestCase):
# Cleanup
dialog.close()
if __name__ == "__main__":
unittest.main()
+22 -20
View File
@@ -6,10 +6,11 @@ import fitz # PyMuPDF
from ebooklib import epub
# Ensure we can import the module
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from abogen.book_parser import get_book_parser, PdfParser, EpubParser, MarkdownParser
class TestBookParser(unittest.TestCase):
def setUp(self):
@@ -32,18 +33,18 @@ class TestBookParser(unittest.TestCase):
def _create_sample_pdf(self):
doc = fitz.open()
# Page 1
page1 = doc.new_page()
page1.insert_text((50, 50), "Page 1 content")
# Add pattern to be cleaned
page1.insert_text((50, 100), "[12]")
page1.insert_text((50, 200), "1") # Page number at bottom
page1.insert_text((50, 100), "[12]")
page1.insert_text((50, 200), "1") # Page number at bottom
# Page 2
page2 = doc.new_page()
page2.insert_text((50, 50), "Page 2 content")
doc.save(self.sample_pdf_path)
doc.close()
@@ -65,7 +66,7 @@ class TestBookParser(unittest.TestCase):
# Basic spine and nav
book.spine = ["nav", c1, c2]
# Add NCX and NAV for compatibility
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
@@ -93,11 +94,11 @@ class TestBookParser(unittest.TestCase):
# 1. Copy sample epub to something.pdf
wrong_ext_path = os.path.join(self.test_dir, "actually_epub.pdf")
shutil.copy(self.sample_epub_path, wrong_ext_path)
# 2. Open it telling parser it IS epub
parser = get_book_parser(wrong_ext_path, file_type="epub")
self.assertIsInstance(parser, EpubParser)
# Should load successfully
parser.load()
self.assertTrue(parser.book is not None)
@@ -109,7 +110,7 @@ class TestBookParser(unittest.TestCase):
self.assertIn("page_1", parser.content_texts)
self.assertIn("page_2", parser.content_texts)
text1 = parser.content_texts["page_1"]
self.assertIn("Page 1 content", text1)
self.assertNotIn("[12]", text1)
@@ -118,7 +119,7 @@ class TestBookParser(unittest.TestCase):
"""Test MarkdownParser splitting logic."""
parser = get_book_parser(self.sample_md_path)
parser.process_content()
# Should have Chapter 1 and Chapter 2 keys (actual keys depend on ID generation)
# Markdown extensions might slugify IDs: "chapter-1"
self.assertIn("chapter-1", parser.content_texts)
@@ -130,7 +131,7 @@ class TestBookParser(unittest.TestCase):
"""Test EpubParser processing."""
parser = get_book_parser(self.sample_epub_path)
parser.process_content()
self.assertIn("intro.xhtml", parser.content_texts)
self.assertIn("chap1.xhtml", parser.content_texts)
self.assertIn("Welcome to the book", parser.content_texts["intro.xhtml"])
@@ -140,7 +141,7 @@ class TestBookParser(unittest.TestCase):
parser = get_book_parser(self.sample_epub_path)
# Processing content triggers metadata extraction in current implementation
parser.process_content()
metadata = parser.get_metadata()
self.assertEqual(metadata.get("title"), "Sample Book")
self.assertEqual(metadata.get("author"), "Test Author")
@@ -149,23 +150,23 @@ class TestBookParser(unittest.TestCase):
"""Test <ol> handling in EpubParser."""
parser = get_book_parser(self.sample_epub_path)
parser.process_content()
text = parser.content_texts.get("chap1.xhtml", "")
self.assertIn("1) Item One", text)
self.assertIn("2) Item Two", text)
def test_find_position_robust_logic(self):
"""Unit test for _find_position_robust on EpubParser."""
parser = EpubParser(self.sample_epub_path) # Instantiate directly
parser = EpubParser(self.sample_epub_path) # Instantiate directly
html = '<html><body><p>Start</p><h1 id="target">Heading</h1><p>End</p></body></html>'
parser.doc_content["dummy.html"] = html
# Test finding ID
pos = parser._find_position_robust("dummy.html", "target")
self.assertGreater(pos, 0)
self.assertTrue(html[pos:].startswith('<h1 id="target"'))
# Test missing ID
pos_missing = parser._find_position_robust("dummy.html", "missing")
self.assertEqual(pos_missing, 0)
@@ -177,7 +178,7 @@ class TestBookParser(unittest.TestCase):
chapters = parser_pdf.get_chapters()
self.assertEqual(len(chapters), 2)
self.assertEqual(chapters[0], ("page_1", "Page 1"))
# MD
parser_md = get_book_parser(self.sample_md_path)
parser_md.process_content() # Must process to get structure
@@ -191,7 +192,7 @@ class TestBookParser(unittest.TestCase):
parser = get_book_parser(self.sample_md_path)
parser.process_content()
text = parser.get_formatted_text()
self.assertIn("<<CHAPTER_MARKER:Chapter 1>>", text)
self.assertIn("Some text", text)
@@ -206,5 +207,6 @@ class TestBookParser(unittest.TestCase):
md_parser = MarkdownParser(self.sample_md_path)
self.assertEqual(md_parser.file_type, "markdown")
if __name__ == "__main__":
unittest.main()
+345 -314
View File
@@ -1,4 +1,10 @@
from abogen.integrations.calibre_opds import CalibreOPDSClient, OPDSEntry, OPDSFeed, OPDSLink, feed_to_dict
from abogen.integrations.calibre_opds import (
CalibreOPDSClient,
OPDSEntry,
OPDSFeed,
OPDSLink,
feed_to_dict,
)
def test_calibre_opds_feed_exposes_series_metadata() -> None:
@@ -158,25 +164,33 @@ This is the detailed summary text.</summary>
def test_calibre_opds_relative_urls_keep_catalog_prefix() -> None:
client = CalibreOPDSClient("http://example.com/opds/")
client = CalibreOPDSClient("http://example.com/opds/")
assert client._make_url("search") == "http://example.com/opds/search"
assert client._make_url("books/sample.epub") == "http://example.com/opds/books/sample.epub"
assert client._make_url("/cover/1") == "http://example.com/cover/1"
assert client._make_url("?page=2") == "http://example.com/opds?page=2"
assert client._make_url("search") == "http://example.com/opds/search"
assert (
client._make_url("books/sample.epub")
== "http://example.com/opds/books/sample.epub"
)
assert client._make_url("/cover/1") == "http://example.com/cover/1"
assert client._make_url("?page=2") == "http://example.com/opds?page=2"
def test_calibre_opds_base_url_without_trailing_slash() -> None:
"""Ensure the client works with base URLs that don't have trailing slashes."""
client = CalibreOPDSClient("http://example.com/api/v1/opds")
"""Ensure the client works with base URLs that don't have trailing slashes."""
client = CalibreOPDSClient("http://example.com/api/v1/opds")
# Base URL should be stored without trailing slash
assert client._base_url == "http://example.com/api/v1/opds"
# Relative paths should resolve as siblings to the base URL
assert client._make_url("catalog") == "http://example.com/api/v1/opds/catalog"
assert client._make_url("search?q=test") == "http://example.com/api/v1/opds/search?q=test"
assert client._make_url("/api/v1/opds/books") == "http://example.com/api/v1/opds/books"
assert client._make_url("?page=2") == "http://example.com/api/v1/opds?page=2"
# Base URL should be stored without trailing slash
assert client._base_url == "http://example.com/api/v1/opds"
# Relative paths should resolve as siblings to the base URL
assert client._make_url("catalog") == "http://example.com/api/v1/opds/catalog"
assert (
client._make_url("search?q=test")
== "http://example.com/api/v1/opds/search?q=test"
)
assert (
client._make_url("/api/v1/opds/books") == "http://example.com/api/v1/opds/books"
)
assert client._make_url("?page=2") == "http://example.com/api/v1/opds?page=2"
def test_calibre_opds_filters_out_unsupported_formats() -> None:
@@ -253,365 +267,382 @@ def test_calibre_opds_navigation_entries_without_download_are_preserved() -> Non
def test_calibre_opds_search_filters_by_title_and_author() -> None:
client = CalibreOPDSClient("http://example.com/catalog")
feed = OPDSFeed(
id="catalog",
title="Catalog",
entries=[
OPDSEntry(id="1", title="The Long Journey", authors=["Alice Smith"]),
OPDSEntry(id="2", title="Hidden Worlds", authors=["Bob Johnson"]),
OPDSEntry(id="3", title="Side Stories", authors=["Cara Nguyen"], series="Journey Tales"),
],
)
client = CalibreOPDSClient("http://example.com/catalog")
feed = OPDSFeed(
id="catalog",
title="Catalog",
entries=[
OPDSEntry(id="1", title="The Long Journey", authors=["Alice Smith"]),
OPDSEntry(id="2", title="Hidden Worlds", authors=["Bob Johnson"]),
OPDSEntry(
id="3",
title="Side Stories",
authors=["Cara Nguyen"],
series="Journey Tales",
),
],
)
filtered = client._filter_feed_entries(feed, "journey alice")
assert [entry.id for entry in filtered.entries] == ["1"]
filtered = client._filter_feed_entries(feed, "journey alice")
assert [entry.id for entry in filtered.entries] == ["1"]
filtered = client._filter_feed_entries(feed, "bob")
assert [entry.id for entry in filtered.entries] == ["2"]
filtered = client._filter_feed_entries(feed, "bob")
assert [entry.id for entry in filtered.entries] == ["2"]
filtered = client._filter_feed_entries(feed, "journey tales")
assert [entry.id for entry in filtered.entries] == ["3"]
filtered = client._filter_feed_entries(feed, "journey tales")
assert [entry.id for entry in filtered.entries] == ["3"]
filtered = client._filter_feed_entries(feed, "missing")
assert filtered.entries == []
filtered = client._filter_feed_entries(feed, "missing")
assert filtered.entries == []
def test_calibre_opds_local_search_follows_next(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
page_one = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])],
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
page_two = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="2", title="The Journey Continues", authors=["Bob Johnson"])],
links={},
)
client = CalibreOPDSClient("http://example.com/catalog")
page_one = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])],
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
page_two = OPDSFeed(
id="catalog",
title="Catalog",
entries=[
OPDSEntry(id="2", title="The Journey Continues", authors=["Bob Johnson"])
],
links={},
)
def fake_fetch(href=None, params=None):
if href == "http://example.com/catalog?page=2":
return page_two
return page_one
def fake_fetch(href=None, params=None):
if href == "http://example.com/catalog?page=2":
return page_two
return page_one
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client._local_search("journey", seed_feed=page_one)
assert [entry.id for entry in result.entries] == ["2"]
result = client._local_search("journey", seed_feed=page_one)
assert [entry.id for entry in result.entries] == ["2"]
def test_calibre_opds_local_search_traverses_navigation(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
root_feed = OPDSFeed(
id="catalog",
title="Catalog",
entries=[
OPDSEntry(
id="nav-authors",
title="Browse Authors",
links=[
OPDSLink(
href="http://example.com/catalog/authors",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
client = CalibreOPDSClient("http://example.com/catalog")
root_feed = OPDSFeed(
id="catalog",
title="Catalog",
entries=[
OPDSEntry(
id="nav-authors",
title="Browse Authors",
links=[
OPDSLink(
href="http://example.com/catalog/authors",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
],
)
],
)
],
links={},
)
authors_feed = OPDSFeed(
id="authors",
title="Authors",
entries=[
OPDSEntry(id="book-42", title="The Count of Monte Cristo", authors=["Alexandre Dumas"])
],
links={},
)
links={},
)
authors_feed = OPDSFeed(
id="authors",
title="Authors",
entries=[
OPDSEntry(
id="book-42",
title="The Count of Monte Cristo",
authors=["Alexandre Dumas"],
)
],
links={},
)
def fake_fetch(href=None, params=None):
if href == "http://example.com/catalog/authors":
return authors_feed
return root_feed
def fake_fetch(href=None, params=None):
if href == "http://example.com/catalog/authors":
return authors_feed
return root_feed
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client._local_search("monte cristo", seed_feed=root_feed)
assert [entry.id for entry in result.entries] == ["book-42"]
result = client._local_search("monte cristo", seed_feed=root_feed)
assert [entry.id for entry in result.entries] == ["book-42"]
def test_calibre_opds_search_falls_back_to_local_search(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
search_page = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])],
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
next_page = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="2", title="Journey in Space", authors=["Cara Nguyen"])],
links={},
)
client = CalibreOPDSClient("http://example.com/catalog")
search_page = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])],
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
next_page = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="2", title="Journey in Space", authors=["Cara Nguyen"])],
links={},
)
def fake_fetch(path=None, params=None):
if path == "search":
return search_page
if path == "http://example.com/catalog?page=2":
return next_page
return search_page
def fake_fetch(path=None, params=None):
if path == "search":
return search_page
if path == "http://example.com/catalog?page=2":
return next_page
return search_page
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client.search("journey")
assert [entry.id for entry in result.entries] == ["2"]
result = client.search("journey")
assert [entry.id for entry in result.entries] == ["2"]
def test_calibre_opds_search_collects_next_page_results(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
first_page = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="1", title="Ryan's Adventure")],
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
second_page = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="2", title="Return of Ryan")],
links={},
)
client = CalibreOPDSClient("http://example.com/catalog")
first_page = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="1", title="Ryan's Adventure")],
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
second_page = OPDSFeed(
id="catalog",
title="Catalog",
entries=[OPDSEntry(id="2", title="Return of Ryan")],
links={},
)
def fake_fetch(path=None, params=None):
if path == "search":
return first_page
if path == "http://example.com/catalog?page=2":
return second_page
if path is None and params is None:
return first_page
return first_page
def fake_fetch(path=None, params=None):
if path == "search":
return first_page
if path == "http://example.com/catalog?page=2":
return second_page
if path is None and params is None:
return first_page
return first_page
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client.search("ryan")
assert [entry.id for entry in result.entries] == ["1", "2"]
result = client.search("ryan")
assert [entry.id for entry in result.entries] == ["1", "2"]
def test_calibre_opds_search_supplements_with_local_navigation(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
search_feed = OPDSFeed(
id="catalog",
title="Catalog",
entries=[
OPDSEntry(id="book-1", title="Ryan's First Mission"),
OPDSEntry(
id="nav-authors",
title="Browse Authors",
links=[
OPDSLink(
href="http://example.com/catalog/authors",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
client = CalibreOPDSClient("http://example.com/catalog")
search_feed = OPDSFeed(
id="catalog",
title="Catalog",
entries=[
OPDSEntry(id="book-1", title="Ryan's First Mission"),
OPDSEntry(
id="nav-authors",
title="Browse Authors",
links=[
OPDSLink(
href="http://example.com/catalog/authors",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
],
),
],
),
],
links={},
)
authors_feed = OPDSFeed(
id="authors",
title="Authors",
entries=[OPDSEntry(id="book-2", title="Chronicles of Ryan")],
links={},
)
links={},
)
authors_feed = OPDSFeed(
id="authors",
title="Authors",
entries=[OPDSEntry(id="book-2", title="Chronicles of Ryan")],
links={},
)
def fake_fetch(path=None, params=None):
if path == "search":
return search_feed
if path == "http://example.com/catalog/authors":
return authors_feed
if path is None and params is None:
return search_feed
return search_feed
def fake_fetch(path=None, params=None):
if path == "search":
return search_feed
if path == "http://example.com/catalog/authors":
return authors_feed
if path is None and params is None:
return search_feed
return search_feed
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client.search("ryan")
assert [entry.id for entry in result.entries] == ["book-1", "book-2"]
result = client.search("ryan")
assert [entry.id for entry in result.entries] == ["book-1", "book-2"]
def test_calibre_opds_browse_letter_traverses_next(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
root_feed = OPDSFeed(
id="catalog",
title="Browse Authors",
entries=[
OPDSEntry(
id="nav-a",
title="A",
links=[
OPDSLink(
href="http://example.com/catalog/authors/a",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
client = CalibreOPDSClient("http://example.com/catalog")
root_feed = OPDSFeed(
id="catalog",
title="Browse Authors",
entries=[
OPDSEntry(
id="nav-a",
title="A",
links=[
OPDSLink(
href="http://example.com/catalog/authors/a",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
],
)
],
)
],
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
page_two = OPDSFeed(
id="catalog",
title="Browse Authors",
entries=[
OPDSEntry(
id="nav-c",
title="C",
links=[
OPDSLink(
href="http://example.com/catalog/authors/c",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
page_two = OPDSFeed(
id="catalog",
title="Browse Authors",
entries=[
OPDSEntry(
id="nav-c",
title="C",
links=[
OPDSLink(
href="http://example.com/catalog/authors/c",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
],
)
],
)
],
links={},
)
letter_feed = OPDSFeed(
id="authors-c",
title="Authors starting with C",
entries=[OPDSEntry(id="author-1", title="Clarke, Arthur C.")],
links={},
)
links={},
)
letter_feed = OPDSFeed(
id="authors-c",
title="Authors starting with C",
entries=[OPDSEntry(id="author-1", title="Clarke, Arthur C.")],
links={},
)
def fake_fetch(href=None, params=None):
if not href:
return root_feed
if href == "http://example.com/catalog?page=2":
return page_two
if href == "http://example.com/catalog/authors/c":
return letter_feed
return root_feed
def fake_fetch(href=None, params=None):
if not href:
return root_feed
if href == "http://example.com/catalog?page=2":
return page_two
if href == "http://example.com/catalog/authors/c":
return letter_feed
return root_feed
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client.browse_letter("C")
assert [entry.id for entry in result.entries] == ["author-1"]
result = client.browse_letter("C")
assert [entry.id for entry in result.entries] == ["author-1"]
def test_calibre_opds_browse_letter_filters_when_missing_navigation(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
titles_feed = OPDSFeed(
id="catalog",
title="Browse Titles",
entries=[
OPDSEntry(id="book-1", title="The Moon is a Harsh Mistress"),
OPDSEntry(id="book-2", title="Another Story"),
],
links={},
)
def test_calibre_opds_browse_letter_filters_when_missing_navigation(
monkeypatch,
) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
titles_feed = OPDSFeed(
id="catalog",
title="Browse Titles",
entries=[
OPDSEntry(id="book-1", title="The Moon is a Harsh Mistress"),
OPDSEntry(id="book-2", title="Another Story"),
],
links={},
)
def fake_fetch(href=None, params=None):
return titles_feed
def fake_fetch(href=None, params=None):
return titles_feed
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client.browse_letter("M")
assert [entry.id for entry in result.entries] == ["book-1"]
result = client.browse_letter("M")
assert [entry.id for entry in result.entries] == ["book-1"]
def test_calibre_opds_browse_letter_collects_paginated_entries(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
first_page = OPDSFeed(
id="catalog",
title="Browse Titles",
entries=[
OPDSEntry(id="book-1", title="Ryan's First Adventure"),
OPDSEntry(id="book-2", title="Another Tale"),
],
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
second_page = OPDSFeed(
id="catalog",
title="Browse Titles",
entries=[OPDSEntry(id="book-3", title="Return of Ryan")],
links={},
)
client = CalibreOPDSClient("http://example.com/catalog")
first_page = OPDSFeed(
id="catalog",
title="Browse Titles",
entries=[
OPDSEntry(id="book-1", title="Ryan's First Adventure"),
OPDSEntry(id="book-2", title="Another Tale"),
],
links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
)
second_page = OPDSFeed(
id="catalog",
title="Browse Titles",
entries=[OPDSEntry(id="book-3", title="Return of Ryan")],
links={},
)
def fake_fetch(href=None, params=None):
if not href:
return first_page
if href == "http://example.com/catalog?page=2":
return second_page
return first_page
def fake_fetch(href=None, params=None):
if not href:
return first_page
if href == "http://example.com/catalog?page=2":
return second_page
return first_page
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client.browse_letter("R")
assert [entry.id for entry in result.entries] == ["book-1", "book-3"]
result = client.browse_letter("R")
assert [entry.id for entry in result.entries] == ["book-1", "book-3"]
def test_calibre_opds_browse_letter_collects_paginated_navigation(monkeypatch) -> None:
client = CalibreOPDSClient("http://example.com/catalog")
root_feed = OPDSFeed(
id="catalog",
title="Browse Authors",
entries=[
OPDSEntry(
id="nav-a",
title="A",
links=[
OPDSLink(
href="http://example.com/catalog/authors/a",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
client = CalibreOPDSClient("http://example.com/catalog")
root_feed = OPDSFeed(
id="catalog",
title="Browse Authors",
entries=[
OPDSEntry(
id="nav-a",
title="A",
links=[
OPDSLink(
href="http://example.com/catalog/authors/a",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
],
),
OPDSEntry(
id="nav-r",
title="R",
links=[
OPDSLink(
href="http://example.com/catalog/authors/r",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
],
),
],
),
OPDSEntry(
id="nav-r",
title="R",
links=[
OPDSLink(
href="http://example.com/catalog/authors/r",
rel="http://opds-spec.org/navigation",
type="application/atom+xml;profile=opds-catalog",
)
links={},
)
letter_feed = OPDSFeed(
id="authors-r",
title="Authors — R",
entries=[
OPDSEntry(id="author-1", title="Ryan, Alice"),
],
),
],
links={},
)
letter_feed = OPDSFeed(
id="authors-r",
title="Authors — R",
entries=[
OPDSEntry(id="author-1", title="Ryan, Alice"),
],
links={"next": OPDSLink(href="http://example.com/catalog/authors/r?page=2", rel="next")},
)
letter_page_two = OPDSFeed(
id="authors-r",
title="Authors — R",
entries=[OPDSEntry(id="author-2", title="Ryan, Bob")],
links={},
)
links={
"next": OPDSLink(
href="http://example.com/catalog/authors/r?page=2", rel="next"
)
},
)
letter_page_two = OPDSFeed(
id="authors-r",
title="Authors — R",
entries=[OPDSEntry(id="author-2", title="Ryan, Bob")],
links={},
)
def fake_fetch(href=None, params=None):
if not href:
return root_feed
if href == "http://example.com/catalog/authors/r":
return letter_feed
if href == "http://example.com/catalog/authors/r?page=2":
return letter_page_two
return root_feed
def fake_fetch(href=None, params=None):
if not href:
return root_feed
if href == "http://example.com/catalog/authors/r":
return letter_feed
if href == "http://example.com/catalog/authors/r?page=2":
return letter_page_two
return root_feed
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
monkeypatch.setattr(client, "fetch_feed", fake_fetch)
result = client.browse_letter("R")
assert [entry.id for entry in result.entries] == ["author-1", "author-2"]
result = client.browse_letter("R")
assert [entry.id for entry in result.entries] == ["author-1", "author-2"]
+17 -5
View File
@@ -43,7 +43,11 @@ def _install_dependency_stubs() -> None:
setattr(numpy_stub, "float32", "float32")
setattr(numpy_stub, "array", lambda data, dtype=None: data)
setattr(numpy_stub, "asarray", lambda data, dtype=None: data)
setattr(numpy_stub, "concatenate", lambda seq, axis=0: sum((list(item) for item in seq), []))
setattr(
numpy_stub,
"concatenate",
lambda seq, axis=0: sum((list(item) for item in seq), []),
)
sys.modules["numpy"] = numpy_stub
if "soundfile" not in sys.modules:
@@ -117,7 +121,9 @@ def test_apply_chapter_overrides_with_custom_text() -> None:
{"index": 1, "enabled": False},
]
selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides)
selected, metadata, diagnostics = _apply_chapter_overrides(
_sample_chapters(), overrides
)
assert len(selected) == 1
assert selected[0].title == "Intro"
@@ -132,7 +138,9 @@ def test_apply_chapter_overrides_uses_original_content_when_text_missing() -> No
{"index": 1, "enabled": True},
]
selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides)
selected, metadata, diagnostics = _apply_chapter_overrides(
_sample_chapters(), overrides
)
assert len(selected) == 1
assert selected[0].title == "Chapter 2"
@@ -152,7 +160,9 @@ def test_apply_chapter_overrides_collects_metadata_updates() -> None:
}
]
selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides)
selected, metadata, diagnostics = _apply_chapter_overrides(
_sample_chapters(), overrides
)
assert len(selected) == 1
assert metadata == {"artist": "Test Author", "year": "2024"}
@@ -164,7 +174,9 @@ def test_apply_chapter_overrides_reports_diagnostics_for_invalid_payload() -> No
{"enabled": True, "title": "Missing"},
]
selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides)
selected, metadata, diagnostics = _apply_chapter_overrides(
_sample_chapters(), overrides
)
assert selected == []
assert metadata == {}
+3 -1
View File
@@ -27,7 +27,9 @@ def test_chunk_voice_spec_prefers_chunk_overrides() -> None:
def test_chunk_voice_spec_falls_back_to_speaker_voice() -> None:
job = SimpleNamespace(voice="base_voice", speakers={"narrator": {"voice": "speaker_voice"}})
job = SimpleNamespace(
voice="base_voice", speakers={"narrator": {"voice": "speaker_voice"}}
)
chunk = {"speaker_id": "narrator"}
assert _chunk_voice_spec(job, chunk, "fallback") == "speaker_voice"
+10 -3
View File
@@ -74,7 +74,9 @@ def test_format_spoken_chapter_title_adds_prefix() -> None:
def test_format_spoken_chapter_title_respects_existing_prefix() -> None:
assert _format_spoken_chapter_title("Chapter 2: Story", 2, True) == "Chapter 2: Story"
assert (
_format_spoken_chapter_title("Chapter 2: Story", 2, True) == "Chapter 2: Story"
)
def test_format_spoken_chapter_title_handles_empty_title() -> None:
@@ -82,7 +84,10 @@ def test_format_spoken_chapter_title_handles_empty_title() -> None:
def test_format_spoken_chapter_title_trims_delimiters() -> None:
assert _format_spoken_chapter_title("7 - Into the Wild", 7, True) == "Chapter 7. Into the Wild"
assert (
_format_spoken_chapter_title("7 - Into the Wild", 7, True)
== "Chapter 7. Into the Wild"
)
def test_headings_equivalent_ignores_case_and_prefix() -> None:
@@ -90,7 +95,9 @@ def test_headings_equivalent_ignores_case_and_prefix() -> None:
def test_strip_duplicate_heading_line_removes_first_match() -> None:
text, removed = _strip_duplicate_heading_line("Chapter 3: Intro\nBody text", "Chapter 3: Intro")
text, removed = _strip_duplicate_heading_line(
"Chapter 3: Intro\nBody text", "Chapter 3: Intro"
)
assert removed is True
assert text.strip() == "Body text"
+1 -1
View File
@@ -117,4 +117,4 @@ def test_series_number_preserves_decimal_positions() -> None:
intro_text = _build_title_intro_text(metadata, "interlude.mp3")
assert "Book 2.5 of the Chronicles." in intro_text
assert "Book 2.5 of the Chronicles." in intro_text
+8 -8
View File
@@ -14,14 +14,14 @@ def _sample_job(formula: str) -> Job:
return cast(
Job,
SimpleNamespace(
voice="__custom_mix",
speakers={
"narrator": {
"resolved_voice": formula,
}
},
chapters=[],
chunks=[{}],
voice="__custom_mix",
speakers={
"narrator": {
"resolved_voice": formula,
}
},
chapters=[],
chunks=[{}],
),
)
+24 -17
View File
@@ -1,15 +1,21 @@
import pytest
from abogen.kokoro_text_normalization import _normalize_grouped_numbers, ApostropheConfig
from abogen.kokoro_text_normalization import (
_normalize_grouped_numbers,
ApostropheConfig,
)
@pytest.fixture
def cfg():
return ApostropheConfig(convert_numbers=True, year_pronunciation_mode="american")
def normalize(text, config):
return _normalize_grouped_numbers(text, config)
class TestDateNormalization:
def test_standard_years(self, cfg):
# 1990 -> nineteen hundred ninety
assert "nineteen hundred ninety" in normalize("In 1990, the web was born.", cfg)
@@ -18,8 +24,10 @@ class TestDateNormalization:
# 2023 -> twenty twenty-three
assert "twenty twenty-three" in normalize("It is currently 2023.", cfg)
# 1905 -> nineteen hundred oh five
assert "nineteen hundred oh five" in normalize("In 1905, Einstein published.", cfg)
assert "nineteen hundred oh five" in normalize(
"In 1905, Einstein published.", cfg
)
def test_future_years(self, cfg):
# 3400 -> thirty-four hundred
assert "thirty-four hundred" in normalize("In the year 3400, we fly.", cfg)
@@ -29,13 +37,13 @@ class TestDateNormalization:
def test_years_with_markers(self, cfg):
# 1021 BC -> ten twenty-one
assert "ten twenty-one" in normalize("It happened in 1021 BC.", cfg)
# 4000 BCE -> forty hundred (or four thousand?)
# _format_year_like logic:
# 4000 BCE -> forty hundred (or four thousand?)
# _format_year_like logic:
# if value % 1000 == 0: return "X thousand"
# 4000 -> four thousand.
# 4000 -> four thousand.
# Let's check 4001 -> forty oh one
assert "forty oh one" in normalize("Ancient times 4001 BCE.", cfg)
def test_addresses_explicit(self, cfg):
# "address" keyword present -> should NOT be year
# 1925 -> one thousand nine hundred twenty-five (default num2words)
@@ -46,11 +54,11 @@ class TestDateNormalization:
assert "one thousand" in res or "nineteen hundred" in res
res = normalize("Please send it to the address: 3400 North Blvd.", cfg)
assert "thirty-four hundred" not in res # Should not be year style
assert "three thousand" in res or "thirty-four hundred" in res
# Wait, "thirty-four hundred" IS how you say 3400 in num2words sometimes?
assert "thirty-four hundred" not in res # Should not be year style
assert "three thousand" in res or "thirty-four hundred" in res
# Wait, "thirty-four hundred" IS how you say 3400 in num2words sometimes?
# num2words(3400) -> "three thousand, four hundred" usually.
# Let's verify what "thirty-four hundred" implies.
# Let's verify what "thirty-four hundred" implies.
# If it's a year: "thirty-four hundred".
# If it's a number: "three thousand four hundred".
assert "three thousand" in res
@@ -62,9 +70,9 @@ class TestDateNormalization:
def test_ambiguous_numbers(self, cfg):
# Just a number, no "address", no markers. Should default to year if 4 digits 1000-9999
assert "nineteen hundred fifty" in normalize("I have 1950 apples.", cfg)
assert "nineteen hundred fifty" in normalize("I have 1950 apples.", cfg)
# This is a known limitation/feature: it aggressively identifies years.
def test_specific_user_examples(self, cfg):
# 1021
assert "ten twenty-one" in normalize("1021", cfg)
@@ -77,10 +85,10 @@ class TestDateNormalization:
# Simulating a title or sentence from the book
# "The Rise of the Robots: Technology and the Threat of a Jobless Future"
# Maybe it mentions a year like 2015 (pub date) or a future date.
# "In 2015, Martin Ford wrote..."
assert "twenty fifteen" in normalize("In 2015, Martin Ford wrote...", cfg)
# "By 2100, robots will..."
assert "twenty-one hundred" in normalize("By 2100, robots will...", cfg)
@@ -113,4 +121,3 @@ class TestDateNormalization:
res = normalize("The addresses are 1925 and 1926.", cfg)
# Expectation: should probably be numbers, not years.
assert "nineteen twenty-five" not in res
+30 -9
View File
@@ -4,7 +4,12 @@ from pathlib import Path
import numpy as np
import pytest
from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES, MARKER_PREFIX, MARKER_SUFFIX, iter_expected_codes
from abogen.debug_tts_samples import (
DEBUG_TTS_SAMPLES,
MARKER_PREFIX,
MARKER_SUFFIX,
iter_expected_codes,
)
from abogen.kokoro_text_normalization import HAS_NUM2WORDS, normalize_for_pipeline
from abogen.normalization_settings import build_apostrophe_config
from abogen.text_extractor import extract_from_path
@@ -16,7 +21,9 @@ def test_debug_epub_contains_all_codes():
assert epub_path.exists()
extraction = extract_from_path(epub_path)
combined = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters)
combined = extraction.combined_text or "\n\n".join(
(c.text or "") for c in extraction.chapters
)
for code in iter_expected_codes():
marker = f"{MARKER_PREFIX}{code}{MARKER_SUFFIX}"
@@ -32,7 +39,9 @@ def test_debug_samples_normalize_smoke():
runtime = dict(settings)
normalized = {
sample.code: normalize_for_pipeline(sample.text, config=apostrophe, settings=runtime)
sample.code: normalize_for_pipeline(
sample.text, config=apostrophe, settings=runtime
)
for sample in DEBUG_TTS_SAMPLES
}
@@ -69,7 +78,9 @@ def test_settings_debug_route_writes_manifest(tmp_path, monkeypatch):
audio[::100] = 0.1
yield _Seg(audio)
monkeypatch.setattr(runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline())
monkeypatch.setattr(
runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline()
)
app = create_app(
{
@@ -87,14 +98,18 @@ def test_settings_debug_route_writes_manifest(tmp_path, monkeypatch):
assert "/settings/debug/" in location
# Extract run id from /settings/debug/<run_id>
run_id = location.rsplit("/settings/debug/", 1)[1].split("?", 1)[0].split("#", 1)[0]
run_id = (
location.rsplit("/settings/debug/", 1)[1].split("?", 1)[0].split("#", 1)[0]
)
manifest_path = tmp_path / "debug" / run_id / "manifest.json"
assert manifest_path.exists()
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
filenames = {item["filename"] for item in manifest.get("artifacts", [])}
assert "overall.wav" in filenames
assert any(name.startswith("case_") and name.endswith(".wav") for name in filenames)
assert any(
name.startswith("case_") and name.endswith(".wav") for name in filenames
)
def test_debug_samples_have_minimum_per_category():
@@ -125,7 +140,9 @@ def test_debug_runner_resolves_profile_voice_before_pipeline(tmp_path, monkeypat
from abogen.webui import debug_tts_runner as runner
# Stub voice setting resolution so we don't depend on the user's profile file.
monkeypatch.setattr(runner, "_resolve_voice_setting", lambda value: ("af_heart", "AM HQ Alt", None))
monkeypatch.setattr(
runner, "_resolve_voice_setting", lambda value: ("af_heart", "AM HQ Alt", None)
)
calls = []
@@ -139,7 +156,9 @@ def test_debug_runner_resolves_profile_voice_before_pipeline(tmp_path, monkeypat
audio = np.zeros(int(0.05 * runner.SAMPLE_RATE), dtype="float32")
yield _Seg(audio)
monkeypatch.setattr(runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline())
monkeypatch.setattr(
runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline()
)
settings = {
"language": "en",
@@ -152,4 +171,6 @@ def test_debug_runner_resolves_profile_voice_before_pipeline(tmp_path, monkeypat
assert manifest.get("run_id")
assert calls
# Must not pass through the profile:* string.
assert all(isinstance(v, str) and not v.lower().startswith("profile:") for v in calls)
assert all(
isinstance(v, str) and not v.lower().startswith("profile:") for v in calls
)
+36 -32
View File
@@ -5,10 +5,11 @@ import sys
from ebooklib import epub
# Ensure import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from abogen.book_parser import get_book_parser
class TestEpubContentSlicing(unittest.TestCase):
"""
Tests for the complex content slicing logic in _execute_nav_parsing_logic.
@@ -34,7 +35,7 @@ class TestEpubContentSlicing(unittest.TestCase):
book = epub.EpubBook()
book.set_identifier("slice123")
book.set_title("Slicing Test Book")
# Create a single content file with two sections
content_html = """
<html>
@@ -50,7 +51,7 @@ class TestEpubContentSlicing(unittest.TestCase):
c1 = epub.EpubHtml(title="Full Content", file_name="content.xhtml", lang="en")
c1.content = content_html
book.add_item(c1)
# Create Nav that points to anchors in the SAME file
# We use EpubHtml for Nav to control content exactly without ebooklib interference
nav_html = """
@@ -64,55 +65,56 @@ class TestEpubContentSlicing(unittest.TestCase):
nav = epub.EpubHtml(title="Nav", file_name="nav.xhtml")
nav.content = nav_html
book.add_item(nav)
book.spine = [nav, c1]
epub.write_epub(self.epub_path, book)
# OPF Patching to valid crash
import zipfile
patched = False
with zipfile.ZipFile(self.epub_path, 'r') as zin:
opf_content = zin.read('EPUB/content.opf').decode('utf-8')
with zipfile.ZipFile(self.epub_path, "r") as zin:
opf_content = zin.read("EPUB/content.opf").decode("utf-8")
if 'toc="ncx"' in opf_content:
opf_content = opf_content.replace('toc="ncx"', '')
opf_content = opf_content.replace('toc="ncx"', "")
patched = True
if patched:
TEMP_EPUB = self.epub_path + ".temp"
with zipfile.ZipFile(TEMP_EPUB, 'w') as zout:
with zipfile.ZipFile(TEMP_EPUB, "w") as zout:
for item in zin.infolist():
if item.filename == 'EPUB/content.opf':
if item.filename == "EPUB/content.opf":
zout.writestr(item, opf_content)
else:
zout.writestr(item, zin.read(item.filename))
if patched:
shutil.move(TEMP_EPUB, self.epub_path)
# Parse
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
# Filter Nav/Intro
chapters = [c for c in chapters if "Chapter" in c[1]]
self.assertEqual(len(chapters), 2)
self.assertEqual(chapters[0][1], "Chapter 1")
self.assertEqual(chapters[1][1], "Chapter 2")
# Check content of Chapter 1
# It should contain "Text for chapter 1" but NOT "Text for chapter 2"
# The parser logic slices from start_pos to next_pos
text1 = parser.content_texts[chapters[0][0]]
self.assertIn("Text for chapter 1", text1)
self.assertNotIn("Text for chapter 2", text1)
# Check content of Chapter 2
text2 = parser.content_texts[chapters[1][0]]
self.assertIn("Text for chapter 2", text2)
def test_list_renumbering(self):
"""
Test that ordered lists are re-numbered when slicing.
@@ -121,7 +123,7 @@ class TestEpubContentSlicing(unittest.TestCase):
book = epub.EpubBook()
book.set_identifier("list123")
book.set_title("List Test Book")
content_html = """
<html>
<body>
@@ -141,7 +143,7 @@ class TestEpubContentSlicing(unittest.TestCase):
c1 = epub.EpubHtml(title="Content", file_name="content.xhtml", lang="en")
c1.content = content_html
book.add_item(c1)
nav_html = """
<nav epub:type="toc">
<ol>
@@ -154,46 +156,48 @@ class TestEpubContentSlicing(unittest.TestCase):
nav.content = nav_html
book.add_item(nav)
book.spine = [nav, c1]
epub.write_epub(self.epub_path, book)
# Patch
import zipfile
patched = False
with zipfile.ZipFile(self.epub_path, 'r') as zin:
opf_content = zin.read('EPUB/content.opf').decode('utf-8')
with zipfile.ZipFile(self.epub_path, "r") as zin:
opf_content = zin.read("EPUB/content.opf").decode("utf-8")
if 'toc="ncx"' in opf_content:
opf_content = opf_content.replace('toc="ncx"', '')
opf_content = opf_content.replace('toc="ncx"', "")
patched = True
if patched:
TEMP_EPUB = self.epub_path + ".temp"
with zipfile.ZipFile(TEMP_EPUB, 'w') as zout:
with zipfile.ZipFile(TEMP_EPUB, "w") as zout:
for item in zin.infolist():
if item.filename == 'EPUB/content.opf':
if item.filename == "EPUB/content.opf":
zout.writestr(item, opf_content)
else:
zout.writestr(item, zin.read(item.filename))
if patched:
shutil.move(TEMP_EPUB, self.epub_path)
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
chapters = [c for c in chapters if "Part" in c[1]]
self.assertEqual(len(chapters), 2)
# Check Part 1 text
text1 = parser.content_texts[chapters[0][0]]
# The parser explicitly replaces li with "1) Item A" style text
self.assertIn("1) Item A", text1)
self.assertIn("2) Item B", text1)
# Check Part 2 text
text2 = parser.content_texts[chapters[1][0]]
# Should convert start="3" to "3) Item C"
self.assertIn("3) Item C", text2)
self.assertIn("4) Item D", text2)
if __name__ == "__main__":
unittest.main()
+36 -10
View File
@@ -37,8 +37,20 @@ def test_build_epub3_package_creates_expected_structure(tmp_path) -> None:
},
]
chunk_markers = [
{"id": "chap0000_p0000", "chapter_index": 0, "chunk_index": 0, "start": 0.0, "end": 1.2},
{"id": "chap0001_p0000", "chapter_index": 1, "chunk_index": 0, "start": 1.2, "end": 2.4},
{
"id": "chap0000_p0000",
"chapter_index": 0,
"chunk_index": 0,
"start": 0.0,
"end": 1.2,
},
{
"id": "chap0001_p0000",
"chapter_index": 1,
"chunk_index": 0,
"start": 1.2,
"end": 2.4,
},
]
chapter_markers = [
{"index": 1, "title": "Chapter 1", "start": 0.0, "end": 1.2},
@@ -76,7 +88,7 @@ def test_build_epub3_package_creates_expected_structure(tmp_path) -> None:
chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8")
assert "Hello world." in chapter_doc
smil_doc = archive.read("OEBPS/smil/chapter_0001.smil").decode("utf-8")
assert "clipBegin=\"00:00:00.000\"" in smil_doc
assert 'clipBegin="00:00:00.000"' in smil_doc
opf_doc = archive.read("OEBPS/content.opf").decode("utf-8")
assert "media-overlay" in opf_doc
assert "media:duration" in opf_doc
@@ -145,7 +157,13 @@ def test_epub3_preserves_original_whitespace(tmp_path) -> None:
]
chunk_markers = [
{"id": chunk["id"], "chapter_index": 0, "chunk_index": chunk["chunk_index"], "start": None, "end": None}
{
"id": chunk["id"],
"chapter_index": 0,
"chunk_index": chunk["chunk_index"],
"start": None,
"end": None,
}
for chunk in chunks
]
@@ -174,7 +192,9 @@ def test_epub3_preserves_original_whitespace(tmp_path) -> None:
assert "Second line" in chunk_section
assert "Third paragraph." in chunk_section
match = re.search(r"<pre class=\"chapter-original\"[^>]*>(.*?)</pre>", chapter_doc, re.DOTALL)
match = re.search(
r"<pre class=\"chapter-original\"[^>]*>(.*?)</pre>", chapter_doc, re.DOTALL
)
assert match is not None
original_text = html.unescape(match.group(1))
assert "Second line\n\nThird paragraph." in original_text
@@ -219,7 +239,13 @@ def test_epub3_sentence_chunks_render_as_paragraphs(tmp_path) -> None:
]
chunk_markers = [
{"id": chunk["id"], "chapter_index": 0, "chunk_index": chunk["chunk_index"], "start": None, "end": None}
{
"id": chunk["id"],
"chapter_index": 0,
"chunk_index": chunk["chunk_index"],
"start": None,
"end": None,
}
for chunk in chunks
]
@@ -244,11 +270,11 @@ def test_epub3_sentence_chunks_render_as_paragraphs(tmp_path) -> None:
assert '<div class="chunk"' not in chapter_doc
assert chapter_doc.count('<p class="chunk-group"') == 2
assert 'First sentence.' in chapter_doc
assert 'Second sentence in same paragraph.' in chapter_doc
assert "First sentence." in chapter_doc
assert "Second sentence in same paragraph." in chapter_doc
first_paragraph_start = chapter_doc.find('<p class="chunk-group"')
first_paragraph_end = chapter_doc.find('</p>', first_paragraph_start)
first_paragraph_end = chapter_doc.find("</p>", first_paragraph_start)
first_paragraph = chapter_doc[first_paragraph_start:first_paragraph_end]
assert "First sentence." in first_paragraph
assert "Second sentence in same paragraph." in first_paragraph
assert "Second sentence in same paragraph." in first_paragraph
+27 -21
View File
@@ -5,10 +5,11 @@ import sys
from ebooklib import epub
# Ensure import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from abogen.book_parser import get_book_parser
class TestEpubHeuristicNav(unittest.TestCase):
"""
Tests for the heuristic fallback in _identify_nav_item (Step 4),
@@ -31,12 +32,12 @@ class TestEpubHeuristicNav(unittest.TestCase):
book = epub.EpubBook()
book.set_identifier("heuristic123")
book.set_title("Heuristic Test Book")
# 1. Add Content
c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
c1.content = "<h1>Chapter 1</h1><p>Text</p>"
book.add_item(c1)
# 2. Add a Nav file BUT as a regular EpubHtml (ITEM_DOCUMENT)
# We do NOT use EpubNav. We do NOT look like a standard nav file if possible,
# but content must contain the magical signature.
@@ -52,66 +53,71 @@ class TestEpubHeuristicNav(unittest.TestCase):
</body>
</html>
"""
# Filename intentionally generic/obscure to avoid filename-based heuristics
# Filename intentionally generic/obscure to avoid filename-based heuristics
# (though current code checks content, not just filename)
nav_file = epub.EpubHtml(title="Hidden Nav", file_name="content_toc.xhtml")
nav_file.content = nav_content
book.add_item(nav_file)
# 3. Setup Spine
book.spine = [nav_file, c1]
# 4. Write EPUB
epub.write_epub(self.epub_path, book)
# 5. Patch OPF to ensure ebooklib didn't sneakily add ITEM_NAVIGATION or toc="ncx"
import zipfile
patched = False
with zipfile.ZipFile(self.epub_path, 'r') as zin:
opf_content = zin.read('EPUB/content.opf').decode('utf-8')
with zipfile.ZipFile(self.epub_path, "r") as zin:
opf_content = zin.read("EPUB/content.opf").decode("utf-8")
# Remove toc="ncx" attribute if present (causes crash if no NCX)
if 'toc="ncx"' in opf_content:
opf_content = opf_content.replace('toc="ncx"', '')
opf_content = opf_content.replace('toc="ncx"', "")
patched = True
# Ideally we'd verify properties="nav" isn't there, but EpubHtml shouldn't add it.
# If ebooklib added it, we might need to strip it to force heuristic.
if 'properties="nav"' in opf_content:
opf_content = opf_content.replace('properties="nav"', '')
opf_content = opf_content.replace('properties="nav"', "")
patched = True
if patched:
TEMP_EPUB = self.epub_path + ".temp"
with zipfile.ZipFile(TEMP_EPUB, 'w') as zout:
with zipfile.ZipFile(TEMP_EPUB, "w") as zout:
for item in zin.infolist():
if item.filename == 'EPUB/content.opf':
if item.filename == "EPUB/content.opf":
zout.writestr(item, opf_content)
else:
zout.writestr(item, zin.read(item.filename))
if patched:
shutil.move(TEMP_EPUB, self.epub_path)
# 6. Verify our setup: Ensure NO ITEM_NAVIGATION exists
# We can inspect using ebooklib again
import ebooklib
check_book = epub.read_epub(self.epub_path)
nav_items = list(check_book.get_items_of_type(ebooklib.ITEM_NAVIGATION))
self.assertEqual(len(nav_items), 0, "Setup failed: explicit navigation item found!")
self.assertEqual(
len(nav_items), 0, "Setup failed: explicit navigation item found!"
)
# 7. Run Parser
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
# 8. Assertions
# Should have found the nav via content scanning
chapter_titles = [c[1] for c in chapters]
self.assertIn("Chapter 1", chapter_titles)
# Also verify we hit the "html" type in identification
# We can't easily check private variables, but success implies it worked.
if __name__ == "__main__":
unittest.main()
+35 -32
View File
@@ -5,10 +5,11 @@ import sys
from ebooklib import epub
# Ensure import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from abogen.book_parser import get_book_parser
class TestEpubHtmlNavParsing(unittest.TestCase):
"""
Tests for EPUB 3 HTML5 Navigation Document parsing logic (_parse_html_nav_li).
@@ -32,12 +33,12 @@ class TestEpubHtmlNavParsing(unittest.TestCase):
book = epub.EpubBook()
book.set_identifier("navtest123")
book.set_title("Nav Test Book")
# Add some content files
c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
c1.content = "<h1>Chapter 1</h1><p>Text 1</p>"
book.add_item(c1)
c2 = epub.EpubHtml(title="Chapter 2", file_name="chap2.xhtml", lang="en")
c2.content = "<h1>Chapter 2</h1><p>Text 2</p>"
book.add_item(c2)
@@ -47,29 +48,29 @@ class TestEpubHtmlNavParsing(unittest.TestCase):
nav = epub.EpubHtml(title="Nav", file_name="nav.xhtml")
nav.content = nav_html_content
book.add_item(nav)
# We must set spine manually
book.spine = [nav, c1, c2]
epub.write_epub(self.epub_path, book)
# Patch the OPF to remove toc="ncx" default which causes crash
# Patch the OPF to remove toc="ncx" default which causes crash
# because we intentionally excluded the legacy NCX file.
import zipfile
with zipfile.ZipFile(self.epub_path, 'r') as zin:
opf_content = zin.read('EPUB/content.opf').decode('utf-8')
opf_content = opf_content.replace('toc="ncx"', '')
with zipfile.ZipFile(self.epub_path, "r") as zin:
opf_content = zin.read("EPUB/content.opf").decode("utf-8")
opf_content = opf_content.replace('toc="ncx"', "")
# Repack
TEMP_EPUB = self.epub_path + ".temp"
with zipfile.ZipFile(TEMP_EPUB, 'w') as zout:
with zipfile.ZipFile(TEMP_EPUB, "w") as zout:
for item in zin.infolist():
if item.filename == 'EPUB/content.opf':
if item.filename == "EPUB/content.opf":
zout.writestr(item, opf_content)
else:
zout.writestr(item, zin.read(item.filename))
shutil.move(TEMP_EPUB, self.epub_path)
def test_basic_html_nav_parsing(self):
@@ -86,14 +87,14 @@ class TestEpubHtmlNavParsing(unittest.TestCase):
</nav>
"""
self._create_epub_with_custom_nav(nav_html)
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
# Filter out "Nav" or "Introduction" prefix content found from the Nav file itself
chapters = [c for c in chapters if "Chapter" in c[1] or "Section" in c[1]]
self.assertEqual(len(chapters), 2)
self.assertEqual(chapters[0][1], "Chapter 1")
self.assertEqual(chapters[1][1], "Chapter 2")
@@ -116,11 +117,11 @@ class TestEpubHtmlNavParsing(unittest.TestCase):
"""
# Note: In this test setup, chap2 is serving as "Section 1.1" effectively
self._create_epub_with_custom_nav(nav_html)
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
ids = [c[1] for c in chapters]
self.assertIn("Chapter 1", ids)
self.assertIn("Section 1.1", ids)
@@ -143,25 +144,26 @@ class TestEpubHtmlNavParsing(unittest.TestCase):
</nav>
"""
self._create_epub_with_custom_nav(nav_html)
parser = get_book_parser(self.epub_path)
parser.process_content()
chapters = parser.get_chapters()
chapter_titles = [c[1] for c in chapters]
self.assertIn("Chapter 1", chapter_titles)
self.assertNotIn("Part I", chapter_titles)
self.assertNotIn("Part I", chapter_titles)
# Check internal structure
# Find the node named "Part I" in the processed structure
root_node = next(node for node in parser.processed_nav_structure if node['title'] == "Part I")
self.assertEqual(root_node['title'], "Part I")
self.assertFalse(root_node['has_content'])
self.assertEqual(len(root_node['children']), 1)
self.assertEqual(root_node['children'][0]['title'], "Chapter 1")
root_node = next(
node for node in parser.processed_nav_structure if node["title"] == "Part I"
)
self.assertEqual(root_node["title"], "Part I")
self.assertFalse(root_node["has_content"])
self.assertEqual(len(root_node["children"]), 1)
self.assertEqual(root_node["children"][0]["title"], "Chapter 1")
def test_identify_nav_item(self):
"""Test the _identify_nav_item method specifically."""
@@ -175,10 +177,11 @@ class TestEpubHtmlNavParsing(unittest.TestCase):
# But here we can call load directly if needed, or rely on normal flow up until navigation
parser.load()
nav_item, nav_type = parser._identify_nav_item()
self.assertEqual(nav_type, "html")
self.assertIsNotNone(nav_item)
self.assertTrue("nav.xhtml" in nav_item.get_name())
if __name__ == "__main__":
unittest.main()
+16 -14
View File
@@ -7,10 +7,11 @@ import logging
from ebooklib import epub
# Ensure import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from abogen.book_parser import get_book_parser
class TestEpubMissingFileErrorHandling(unittest.TestCase):
"""
Tests for robust error handling and recovery in the book parser.
@@ -22,8 +23,8 @@ class TestEpubMissingFileErrorHandling(unittest.TestCase):
shutil.rmtree(self.test_dir)
os.makedirs(self.test_dir)
self.broken_epub_path = os.path.join(self.test_dir, "missing_file.epub")
# Suppress logging during tests to keep output clean,
# Suppress logging during tests to keep output clean,
# or capture it if we want to assert on warnings.
# For now, we just let it be or set level to ERROR.
logging.getLogger().setLevel(logging.ERROR)
@@ -39,27 +40,27 @@ class TestEpubMissingFileErrorHandling(unittest.TestCase):
book = epub.EpubBook()
book.set_identifier("broken123")
book.set_title("Broken Book")
# 1. Add a valid chapter
c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
c1.content = "<h1>Chapter 1</h1><p>Survivable content.</p>"
book.add_item(c1)
# 2. Add a 'ghost' chapter that we will delete later
c2 = epub.EpubHtml(title="Ghost Chapter", file_name="ghost.xhtml", lang="en")
c2.content = "<h1>Ghost</h1><p>I will disappear.</p>"
book.add_item(c2)
book.spine = ["nav", c1, c2]
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
temp_path = os.path.join(self.test_dir, "temp.epub")
epub.write_epub(temp_path, book)
# 3. Physically remove 'ghost.xhtml' from the ZIP
with zipfile.ZipFile(temp_path, 'r') as zin:
with zipfile.ZipFile(self.broken_epub_path, 'w') as zout:
with zipfile.ZipFile(temp_path, "r") as zin:
with zipfile.ZipFile(self.broken_epub_path, "w") as zout:
for item in zin.infolist():
# Copy everything EXCEPT the ghost file
# Note: ebooklib might put files in OEPS/ or EPUB/ folders depending on version,
@@ -73,14 +74,14 @@ class TestEpubMissingFileErrorHandling(unittest.TestCase):
Should log a warning instead of raising KeyError.
"""
self._create_broken_epub()
try:
parser = get_book_parser(self.broken_epub_path)
parser.process_content()
# 1. Ensure process didn't crash
self.assertTrue(True, "Parser should not crash on missing file")
# 2. Ensure valid content was extracted
# Identify the ID for chap1.xhtml (usually file path based)
# Since IDs can vary, we check if ANY content contains our known string
@@ -90,11 +91,12 @@ class TestEpubMissingFileErrorHandling(unittest.TestCase):
chap1_found = True
break
self.assertTrue(chap1_found, "The valid chapter should still be processed")
except KeyError:
self.fail("Parser raised KeyError instead of handling the missing file!")
except Exception as e:
self.fail(f"Parser raised unexpected exception: {e}")
if __name__ == "__main__":
unittest.main()
+22 -22
View File
@@ -5,13 +5,14 @@ import sys
from ebooklib import epub
# Ensure we can import the module
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from abogen.book_parser import get_book_parser, EpubParser
class TestEpubNcxParsing(unittest.TestCase):
"""
Focused tests for NCX navigation scenarios, ensuring legacy/compatibility
Focused tests for NCX navigation scenarios, ensuring legacy/compatibility
modes work when HTML5 Navigation is missing.
"""
@@ -50,9 +51,9 @@ class TestEpubNcxParsing(unittest.TestCase):
# Add default NCX and generic spine
book.add_item(epub.EpubNcx())
# IMPORTANT: Do NOT add EpubNav() here, that's what we are testing!
book.spine = ["nav"] + epub_chapters
epub.write_epub(self.ncx_only_epub_path, book)
def test_ncx_only_parsing(self):
@@ -63,28 +64,28 @@ class TestEpubNcxParsing(unittest.TestCase):
# 1. Setup Data
chapters_data = [
("Chapter 1", "This is the first chapter."),
("Chapter 2", "This is the second chapter.")
("Chapter 2", "This is the second chapter."),
]
self._create_ncx_only_epub(chapters_data)
# 2. Run Parser
parser = get_book_parser(self.ncx_only_epub_path)
parser.process_content()
# 3. Verify Breakdown
# We expect detailed breakdown based on NCX
chapters = parser.get_chapters()
# Should find exactly 2 chapters based on the Toc
self.assertEqual(len(chapters), 2, "Should have 2 chapters extracted from NCX")
# Check Titles and Sequence
self.assertEqual(chapters[0][1], "Chapter 1")
self.assertEqual(chapters[1][1], "Chapter 2")
# Verify content was extracted
# Note: 'src' in chapters usually points to file_name if no fragments
id_1 = chapters[0][0]
id_1 = chapters[0][0]
self.assertIn("This is the first chapter", parser.content_texts[id_1])
def test_nested_ncx_parsing(self):
@@ -94,7 +95,7 @@ class TestEpubNcxParsing(unittest.TestCase):
book = epub.EpubBook()
book.set_identifier("nested_ncx")
book.set_title("Nested NCX")
# Create one big file with sections
c1 = epub.EpubHtml(title="Main Chapter", file_name="main.xhtml", lang="en")
c1.content = """
@@ -108,33 +109,32 @@ class TestEpubNcxParsing(unittest.TestCase):
# Manually construct nested TOC because ebooklib's default helpers are simple
# EbookLib automatically builds NCX from book.toc
# Nested tuple structure: (Section, (Subsection, Sub-subsection))
# We need to link to Fragments for this to really test nested NCX pointing to same file
# EbookLib Link object: epub.Link(href, title, uid)
link_root = epub.Link("main.xhtml#intro", "Introduction", "intro")
link_sect = epub.Link("main.xhtml#sect1", "Section 1", "sect1")
# Structure: Intro -> Section 1 (as child)
book.toc = (
(link_root, (link_sect, )),
)
book.toc = ((link_root, (link_sect,)),)
book.add_item(epub.EpubNcx())
book.spine = ["nav", c1]
epub.write_epub(self.ncx_only_epub_path, book)
# Parse
parser = get_book_parser(self.ncx_only_epub_path)
parser.process_content()
chapters = parser.get_chapters()
# Depending on how the parser flattens, we should see both entries
titles = [node[1] for node in chapters]
self.assertIn("Introduction", titles)
self.assertIn("Section 1", titles)
if __name__ == "__main__":
unittest.main()
+25 -22
View File
@@ -7,10 +7,11 @@ import ebooklib
from unittest.mock import MagicMock
# Ensure import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from abogen.book_parser import get_book_parser
class TestEpubStandardNav(unittest.TestCase):
"""
Tests for the standard ITEM_NAVIGATION discovery in _identify_nav_item.
@@ -33,38 +34,39 @@ class TestEpubStandardNav(unittest.TestCase):
book = epub.EpubBook()
book.set_identifier("stdnav123")
book.set_title("Standard Nav Test")
c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
c1.content = "<h1>Chapter 1</h1><p>Text 1</p>"
book.add_item(c1)
# Use Standard EpubNav
nav = epub.EpubNav()
book.add_item(nav)
book.spine = [nav, c1]
epub.write_epub(self.epub_path, book)
# "Zip Surgery" Patch:
# ebooklib unconditionally adds `toc="ncx"` to the spine, even for EPUB 3 files that purely use HTML Nav.
# This creates a dangling reference to a non-existent "ncx" item, causing ebooklib to crash on read.
# We manually remove this attribute to ensure the test EPUB is valid and readable.
# TODO - find real world examples of EPUB 3 files that use HTML Nav
import zipfile
patched = False
with zipfile.ZipFile(self.epub_path, 'r') as zin:
opf_content = zin.read('EPUB/content.opf').decode('utf-8')
with zipfile.ZipFile(self.epub_path, "r") as zin:
opf_content = zin.read("EPUB/content.opf").decode("utf-8")
if 'toc="ncx"' in opf_content:
opf_content = opf_content.replace('toc="ncx"', '')
opf_content = opf_content.replace('toc="ncx"', "")
patched = True
TEMP_EPUB = self.epub_path + ".temp"
with zipfile.ZipFile(TEMP_EPUB, 'w') as zout:
with zipfile.ZipFile(TEMP_EPUB, "w") as zout:
for item in zin.infolist():
if item.filename == 'EPUB/content.opf':
if item.filename == "EPUB/content.opf":
zout.writestr(item, opf_content)
else:
zout.writestr(item, zin.read(item.filename))
if patched:
shutil.move(TEMP_EPUB, self.epub_path)
@@ -78,18 +80,18 @@ class TestEpubStandardNav(unittest.TestCase):
This exercises the first branch of _identify_nav_item.
"""
parser = self._create_and_load_epub()
# Inject an item that mocks the ITEM_NAVIGATION type behavior
# (This simulates a library/parser that correctly types the item as 4)
mock_nav = MagicMock()
mock_nav.get_name.return_value = "nav.xhtml"
mock_nav.get_type.return_value = ebooklib.ITEM_NAVIGATION
# We append this mock to the book items to ensure get_items_of_type(ITEM_NAVIGATION) finds it
parser.book.items.append(mock_nav)
nav_item, nav_type = parser._identify_nav_item()
self.assertEqual(nav_type, "html")
self.assertEqual(nav_item.get_name(), "nav.xhtml")
# Verify we are getting the object we expect (implied by success)
@@ -100,31 +102,32 @@ class TestEpubStandardNav(unittest.TestCase):
This is the standard EPUB 3 behavior and exercises the fallback branch.
"""
parser = self._create_and_load_epub()
# Locate the generic 'nav' item loaded by ebooklib
original_nav = parser.book.get_item_with_id("nav")
self.assertIsNotNone(original_nav)
# "Fix" the object to match what we expect from a correct EPUB 3 read:
# It should have properties=['nav'].
# We use a real EpubNav object to ensure structural correctness.
proper_nav = epub.EpubNav(uid=original_nav.id, file_name=original_nav.file_name)
proper_nav.content = original_nav.content
proper_nav.properties = ['nav']
proper_nav.properties = ["nav"]
# Swap it into the book items list
try:
idx = parser.book.items.index(original_nav)
parser.book.items[idx] = proper_nav
except ValueError:
self.fail("Could not find original nav item to swap")
nav_item, nav_type = parser._identify_nav_item()
self.assertEqual(nav_type, "html")
self.assertEqual(nav_item.get_name(), "nav.xhtml")
# Check that we actually found the one with properties
self.assertEqual(getattr(nav_item, 'properties', []), ['nav'])
self.assertEqual(getattr(nav_item, "properties", []), ["nav"])
if __name__ == "__main__":
unittest.main()
+15 -5
View File
@@ -30,16 +30,22 @@ def _sample_job(tmp_path: Path) -> Job:
)
def test_prepare_project_layout_uses_timestamped_folder(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
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)
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 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
@@ -48,7 +54,9 @@ def test_prepare_project_layout_uses_timestamped_folder(monkeypatch: pytest.Monk
assert output_path == project_root / "Sample_Title.mp3"
def test_prepare_project_layout_creates_project_subdirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
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(
@@ -56,7 +64,9 @@ def test_prepare_project_layout_creates_project_subdirs(monkeypatch: pytest.Monk
lambda: "20250101-120500",
)
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, tmp_path)
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(
job, tmp_path
)
assert audio_dir == project_root / "audio"
assert subtitle_dir == project_root / "subtitles"
+8 -4
View File
@@ -102,7 +102,9 @@ def test_resolve_voice_setting_handles_profile_reference():
}
}
voice, profile_name, language = resolve_voice_setting("profile:Blend", profiles=profiles)
voice, profile_name, language = resolve_voice_setting(
"profile:Blend", profiles=profiles
)
assert voice == "af_nova*0.5+am_liam*0.5"
assert profile_name == "Blend"
@@ -112,9 +114,11 @@ def test_resolve_voice_setting_handles_profile_reference():
def test_apply_prepare_form_updates_closing_outro_flag():
pending = _make_pending_job()
pending.read_closing_outro = True
form = MultiDict({
"read_closing_outro": "false",
})
form = MultiDict(
{
"read_closing_outro": "false",
}
)
apply_prepare_form(pending, form)
@@ -13,7 +13,9 @@ def test_preview_applies_manual_override_before_normalization(monkeypatch):
def normalize_for_pipeline(text):
return text
monkeypatch.setitem(__import__("sys").modules, "abogen.kokoro_text_normalization", _Norm)
monkeypatch.setitem(
__import__("sys").modules, "abogen.kokoro_text_normalization", _Norm
)
# And stub the kokoro pipeline path so generate_preview_audio won't proceed.
# We'll instead validate by calling the override logic through generate_preview_audio
@@ -28,7 +30,11 @@ def test_preview_applies_manual_override_before_normalization(monkeypatch):
captured["text"] = text
return iter(())
monkeypatch.setitem(__import__("sys").modules, "abogen.tts_supertonic", type("M", (), {"SupertonicPipeline": DummyPipeline}))
monkeypatch.setitem(
__import__("sys").modules,
"abogen.tts_supertonic",
type("M", (), {"SupertonicPipeline": DummyPipeline}),
)
try:
preview.generate_preview_audio(
+13 -2
View File
@@ -1,16 +1,21 @@
import pytest
from unittest.mock import patch
from abogen.kokoro_text_normalization import normalize_for_pipeline, DEFAULT_APOSTROPHE_CONFIG
from abogen.kokoro_text_normalization import (
normalize_for_pipeline,
DEFAULT_APOSTROPHE_CONFIG,
)
from abogen.normalization_settings import build_apostrophe_config, _SETTINGS_DEFAULTS
def normalize(text, overrides=None):
settings = dict(_SETTINGS_DEFAULTS)
if overrides:
settings.update(overrides)
config = build_apostrophe_config(settings=settings, base=DEFAULT_APOSTROPHE_CONFIG)
return normalize_for_pipeline(text, config=config, settings=settings)
def test_year_pronunciation():
# 1925 -> Nineteen Hundred Twenty Five
normalized = normalize("1925")
@@ -24,6 +29,7 @@ def test_year_pronunciation():
assert "twenty twenty" in normalized.lower()
assert "five" in normalized.lower()
def test_currency_pronunciation():
# $1.00 -> One dollar (no zero cents)
normalized = normalize("$1.00")
@@ -37,6 +43,7 @@ def test_currency_pronunciation():
assert "one dollar" in normalized.lower()
assert "five cents" in normalized.lower()
def test_url_pronunciation():
# https://www.amazon.com -> amazon dot com
normalized = normalize("https://www.amazon.com")
@@ -50,6 +57,7 @@ def test_url_pronunciation():
print(f"www.google.com -> {normalized}")
assert "google dot com" in normalized.lower()
def test_roman_numerals_world_war():
# World War I -> World War One
normalized = normalize("World War I")
@@ -61,6 +69,7 @@ def test_roman_numerals_world_war():
print(f"World War II -> {normalized}")
assert "world war two" in normalized.lower()
def test_footnote_removal():
# Bob is awesome1. -> Bob is awesome.
normalized = normalize("Bob is awesome1.")
@@ -74,8 +83,10 @@ def test_footnote_removal():
assert "citation needed." in normalized.lower()
assert "[1]" not in normalized
def test_manual_override_normalization():
from abogen.entity_analysis import normalize_manual_override_token
assert normalize_manual_override_token("The") == "the"
assert normalize_manual_override_token(" A ") == "a"
assert normalize_manual_override_token("word") == "word"
+13 -3
View File
@@ -2,7 +2,13 @@ from __future__ import annotations
import io
import time
from abogen.webui.service import Job, JobStatus, build_service, _JOB_LOGGER, build_audiobookshelf_metadata
from abogen.webui.service import (
Job,
JobStatus,
build_service,
_JOB_LOGGER,
build_audiobookshelf_metadata,
)
def test_service_processes_job(tmp_path):
@@ -47,7 +53,11 @@ def test_service_processes_job(tmp_path):
)
deadline = time.time() + 5
while time.time() < deadline and job.status not in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
while time.time() < deadline and job.status not in {
JobStatus.COMPLETED,
JobStatus.FAILED,
JobStatus.CANCELLED,
}:
time.sleep(0.05)
service.shutdown()
@@ -287,4 +297,4 @@ def test_audiobookshelf_metadata_allows_decimal_sequence(tmp_path):
metadata = build_audiobookshelf_metadata(job)
assert metadata["seriesSequence"] == "4.5"
assert metadata["seriesSequence"] == "4.5"
+10 -7
View File
@@ -24,9 +24,12 @@ def _chunk(text: str, idx: int) -> dict:
def test_analyze_speakers_infers_gender_from_pronouns():
chunks = [
_chunk("\"Greetings,\" said John. He adjusted his hat as he smiled.", 0),
_chunk("\"Hello,\" said Mary. She straightened her dress as she introduced herself.", 1),
_chunk("\"Nice to meet you,\" said Alex.", 2),
_chunk('"Greetings," said John. He adjusted his hat as he smiled.', 0),
_chunk(
'"Hello," said Mary. She straightened her dress as she introduced herself.',
1,
),
_chunk('"Nice to meet you," said Alex.', 2),
]
analysis = analyze_speakers(_chapters(), chunks, threshold=1, max_speakers=0)
@@ -63,8 +66,8 @@ def test_analyze_speakers_ignores_leading_stopwords():
def test_analyze_speakers_applies_threshold_suppression():
chunks = [
_chunk("\"Hello there,\" said Narrator.", 0),
_chunk("\"It is lying,\" said Green.", 1),
_chunk('"Hello there," said Narrator.', 0),
_chunk('"It is lying," said Green.', 1),
]
analysis = analyze_speakers(_chapters(), chunks, threshold=3, max_speakers=0)
@@ -78,7 +81,7 @@ def test_analyze_speakers_applies_threshold_suppression():
def test_sample_excerpt_includes_context_paragraphs():
chunks = [
_chunk("The hallway was quiet as footsteps approached.", 0),
_chunk('\"Open the door,\" said John as he reached for the handle.', 1),
_chunk('"Open the door," said John as he reached for the handle.', 1),
_chunk("Mary watched him closely, unsure of his intent.", 2),
]
@@ -89,5 +92,5 @@ def test_sample_excerpt_includes_context_paragraphs():
assert john.sample_quotes, "Expected John to have at least one sample quote"
excerpt = john.sample_quotes[0]["excerpt"]
assert "The hallway was quiet" in excerpt
assert "\"Open the door,\" said John" in excerpt
assert '"Open the door," said John' in excerpt
assert "Mary watched him closely" in excerpt
+9 -3
View File
@@ -6,7 +6,9 @@ from abogen.text_extractor import extract_from_path
from abogen.utils import calculate_text_length
ASSET = Path("test_assets/alexandre-dumas_the-count-of-monte-cristo_chapman-and-hall.epub")
ASSET = Path(
"test_assets/alexandre-dumas_the-count-of-monte-cristo_chapman-and-hall.epub"
)
def test_epub_character_counts_align_with_calculated_total():
@@ -37,8 +39,12 @@ def test_epub_series_metadata_extracted_from_opf_meta(tmp_path):
book.add_author("Example Author")
# Calibre-style series metadata
book.add_metadata("OPF", "meta", "", {"name": "calibre:series", "content": "Example Saga"})
book.add_metadata("OPF", "meta", "", {"name": "calibre:series_index", "content": "2"})
book.add_metadata(
"OPF", "meta", "", {"name": "calibre:series", "content": "Example Saga"}
)
book.add_metadata(
"OPF", "meta", "", {"name": "calibre:series_index", "content": "2"}
)
chapter = epub.EpubHtml(title="Chapter 1", file_name="chap_01.xhtml", lang="en")
chapter.content = "<h1>Chapter 1</h1><p>Hello</p>"
+30 -12
View File
@@ -16,14 +16,22 @@ from abogen.normalization_settings import (
from abogen.spacy_contraction_resolver import resolve_ambiguous_contractions
SPACY_RESOLVER_AVAILABLE = bool(resolve_ambiguous_contractions("It's been a long time."))
SPACY_RESOLVER_AVAILABLE = bool(
resolve_ambiguous_contractions("It's been a long time.")
)
def _normalize_text(text: str, *, normalization_overrides: dict[str, object] | None = None) -> str:
def _normalize_text(
text: str, *, normalization_overrides: dict[str, object] | None = None
) -> str:
runtime_settings = get_runtime_settings()
if normalization_overrides:
runtime_settings = apply_normalization_overrides(runtime_settings, normalization_overrides)
config = build_apostrophe_config(settings=runtime_settings, base=DEFAULT_APOSTROPHE_CONFIG)
runtime_settings = apply_normalization_overrides(
runtime_settings, normalization_overrides
)
config = build_apostrophe_config(
settings=runtime_settings, base=DEFAULT_APOSTROPHE_CONFIG
)
return normalize_for_pipeline(text, config=config, settings=runtime_settings)
@@ -202,7 +210,9 @@ def test_contractions_can_be_kept_when_override_disabled() -> None:
def test_sibilant_possessives_remain_when_marking_disabled() -> None:
normalized = _normalize_text(
"The boss's chair wobbled.",
normalization_overrides={"normalization_apostrophes_sibilant_possessives": False},
normalization_overrides={
"normalization_apostrophes_sibilant_possessives": False
},
)
assert "boss's" in normalized
assert "boss iz" not in normalized.lower()
@@ -276,10 +286,13 @@ def test_spacy_disambiguates_she_would() -> None:
@pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable")
def test_sample_sentence_handles_complex_contractions() -> None:
sample = "I've heard the captain'll arrive by dusk, but they'd said the same yesterday."
sample = (
"I've heard the captain'll arrive by dusk, but they'd said the same yesterday."
)
normalized = _normalize_text(sample)
assert (
"I have heard the captain will arrive by dusk, but they had said the same yesterday." == normalized
"I have heard the captain will arrive by dusk, but they had said the same yesterday."
== normalized
)
@@ -316,9 +329,12 @@ def mock_settings():
"normalization_footnotes": True,
"normalization_numbers_year_style": "american",
}
with patch("tests.test_text_normalization.get_runtime_settings", return_value=defaults):
with patch(
"tests.test_text_normalization.get_runtime_settings", return_value=defaults
):
yield
def test_currency_magnitude():
cases = [
("$2 million", "two million dollars"),
@@ -331,13 +347,15 @@ def test_currency_magnitude():
("$2.50", "two dollars, fifty cents"),
("$100", "one hundred dollars"),
]
settings = {
"normalization_numbers": True,
"normalization_currency": True,
"normalization_apostrophe_mode": "spacy"
"normalization_apostrophe_mode": "spacy",
}
for input_text, expected in cases:
normalized = _normalize_text(input_text, normalization_overrides=settings)
assert expected.lower() in normalized.lower(), f"Failed for {input_text}: got '{normalized}'"
assert (
expected.lower() in normalized.lower()
), f"Failed for {input_text}: got '{normalized}'"
+5 -1
View File
@@ -4,7 +4,11 @@ from typing import cast
import pytest
from abogen.constants import VOICES_INTERNAL
from abogen.voice_cache import LocalEntryNotFoundError, _CACHED_VOICES, ensure_voice_assets
from abogen.voice_cache import (
LocalEntryNotFoundError,
_CACHED_VOICES,
ensure_voice_assets,
)
from abogen.webui.conversion_runner import _collect_required_voice_ids
from abogen.webui.service import Job