mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Reformat using black
This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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 == {}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=[{}],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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}'"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user