import pytest from ebooklib import epub from pathlib import Path from datetime import date from chitai.services.metadata_extractor import ( EpubExtractor, Extractor, PdfExtractor, split_edition, ) @pytest.mark.asyncio() class TestEpubExtractor: async def test_extraction_by_path(self): path = Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub") metadata = await EpubExtractor.extract_metadata(path) assert metadata["title"] == "Moby Dick; Or, The Whale" assert metadata["authors"] == ["Herman Melville"] assert metadata["published_date"] == date(year=2001, month=7, day=1) EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf") @pytest.mark.asyncio() class TestIdentifierMerging: """A book's formats each contribute identifiers; none of them replaces the rest.""" async def test_every_format_contributes(self, monkeypatch: pytest.MonkeyPatch) -> None: """ Identifiers are a collection, not a single value. Merging the whole dict meant the last format to report won outright: an EPUB declaring an ASIN, a Google volume id and a Calibre id kept none of them once a PDF contributed one ISBN. """ async def epub(_file): return { "title": "How Linux Works", "identifiers": {"isbn-13": "9781718500419", "asin": "1718500408"}, } async def pdf(_file): return {"identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"}} monkeypatch.setattr(EpubExtractor, "extract_metadata", epub) monkeypatch.setattr(PdfExtractor, "extract_metadata", pdf) metadata = await Extractor.extract_metadata( [Path("How Linux Works.epub"), Path("How Linux Works.pdf")] ) assert metadata["identifiers"] == { # Declared by the publisher's toolchain, so it outranks the PDF's, which # was scraped off a copyright page that also prints the print edition's. "isbn-13": "9781718500419", "asin": "1718500408", "isbn-10": "1593270356", } async def test_a_second_format_that_finds_nothing_erases_nothing(self) -> None: """The PDF fixture carries no ISBN, so it must leave the EPUB's alone.""" metadata = await Extractor.extract_metadata([EPUB, PDF]) assert metadata["identifiers"] == {"id": "http://www.gutenberg.org/5200"} async def test_one_format_on_its_own_is_unaffected(self) -> None: metadata = await Extractor.extract_metadata([EPUB]) assert metadata["identifiers"] == {"id": "http://www.gutenberg.org/5200"} async def test_no_identifiers_anywhere_leaves_the_field_absent(self) -> None: """An empty dict would count as extracted metadata and overwrite nothing.""" metadata = await Extractor.extract_metadata([PDF]) assert "identifiers" not in metadata class TestSplitEdition: """An edition is a field on the book, not part of what the book is called.""" @pytest.mark.parametrize( ("title", "stripped", "edition"), [ # Every form below is one that turned up in a real library. ("Fluent Python, 2nd Edition", "Fluent Python", 2), ("Building Microservices, 2E", "Building Microservices", 2), ("Digital Image Processing, 4e", "Digital Image Processing", 4), ( "Network Security Essentials: Applications and Standards/6e", "Network Security Essentials: Applications and Standards", 6, ), ( "Refactoring: Improving the Design of Existing Code (2nd edition)", "Refactoring: Improving the Design of Existing Code", 2, ), # Ordinal words, including a qualifier sitting inside the statement. ( "The Art of Computer Programming: Volume 1 / Fundamental Algorithms, Third Edition", "The Art of Computer Programming: Volume 1 / Fundamental Algorithms", 3, ), ( "Introduction to the Theory of Computation, Third International Edition", "Introduction to the Theory of Computation", 3, ), # Mid-title, before a subtitle and before a trailing author. ( "How Linux Works, 3rd Edition: What Every Superuser Should Know", "How Linux Works: What Every Superuser Should Know", 3, ), ( "Code Complete, 2nd Edition - Steve McConnell", "Code Complete - Steve McConnell", 2, ), # An underscore between the number and the "e", beside an unnumbered # qualifier that has nowhere to go in an integer column and so stays put. ( "Cryptography and Network Security, Global Edition, 8_e - Stallings", "Cryptography and Network Security, Global Edition - Stallings", 8, ), ], ) def test_editions_are_split_out(self, title: str, stripped: str, edition: int) -> None: assert split_edition(title) == (stripped, edition) @pytest.mark.parametrize( "title", [ # A number alone is never an edition — these are titles. "Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13", "Slaughterhouse 5", "The Art of Computer Programming: Volume 1", # "Edition" with no number cannot be stored, so it stays where it can # still be read. "Cryptography and Network Security: Principles and Practice, Global Edition", "Building Microservices", ], ) def test_titles_are_left_alone(self, title: str) -> None: assert split_edition(title) == (title, None) def test_a_title_that_is_only_an_edition_is_kept(self) -> None: """Stripping must never leave a book with no title at all.""" assert split_edition("2nd Edition") == ("2nd Edition", None) @pytest.mark.parametrize("title", ["", None]) def test_nothing_yields_nothing(self, title: str | None) -> None: assert split_edition(title) == (title, None) @pytest.mark.asyncio() class TestEditionFromFiles: async def test_extraction_moves_the_edition_off_the_title(self) -> None: """The PDF fixture calls itself a 2nd edition in its own metadata title.""" metadata = await Extractor.extract_metadata([PDF]) assert metadata["title"] == "The Project Gutenberg eBook #33283: Calculus Made Easy" assert metadata["edition"] == 2 class TestEpubPublisher: """The publisher was looked up and then dropped on the floor.""" def test_a_declared_publisher_is_returned(self) -> None: """ The lookup discarded its own result and fell off the end of the function, so every EPUB reported no publisher no matter what it said. """ book = epub.EpubBook() book.add_metadata("DC", "publisher", "No Starch Press") assert EpubExtractor._extract_publisher(book) == "No Starch Press" def test_no_publisher_is_none(self) -> None: assert EpubExtractor._extract_publisher(epub.EpubBook()) is None