"""Tests for the normalization behind book-level duplicate detection.""" import pytest from chitai.services.matching import ( normalize_author, normalize_identifier, normalize_title, ) from chitai.services.utils import isbn10_to_isbn13 class TestNormalizeTitle: """Two copies of one book rarely agree on how the title is written.""" @pytest.mark.parametrize( ("title", "expected"), [ ("The Metamorphosis", "metamorphosis"), ("Metamorphosis", "metamorphosis"), ("METAMORPHOSIS", "metamorphosis"), ("A Tale of Two Cities", "tale of two cities"), ("An Enquiry", "enquiry"), # Accents, punctuation and ampersands are spelling, not identity. ("Les Misérables", "les miserables"), ("Moby Dick; Or, The Whale", "moby dick or the whale"), ("Sense & Sensibility", "sense and sensibility"), # Bracketed asides and trailing edition noise say nothing about the book. ("Frankenstein (Illustrated)", "frankenstein"), ("Frankenstein [Kindle Edition]", "frankenstein"), ("Frankenstein, 2nd Edition", "frankenstein"), ("Frankenstein Revised Edition", "frankenstein"), ("Dune Deluxe Edition Illustrated", "dune"), ("", ""), (None, ""), ], ) def test_titles_that_should_agree(self, title: str | None, expected: str) -> None: assert normalize_title(title) == expected def test_a_qualifier_that_is_the_title_survives(self) -> None: """A trailing qualifier is noise; the same word at the front is the book.""" assert normalize_title("The Illustrated Man") == "illustrated man" def test_normalization_never_empties_a_title(self) -> None: """An article-only title is not improved by having no article left.""" assert normalize_title("The") == "the" class TestNormalizeAuthor: """One person, written down several ways.""" @pytest.mark.parametrize( ("name", "expected"), [ ("Franz Kafka", "franz kafka"), ("Kafka, Franz", "franz kafka"), ("KAFKA, FRANZ", "franz kafka"), ("Émile Zola", "emile zola"), ("Doyle, Arthur Conan", "arthur conan doyle"), # Runs of initials are joined, so spacing them out changes nothing. ("J.R.R. Tolkien", "jrr tolkien"), ("J. R. R. Tolkien", "jrr tolkien"), ("JRR Tolkien", "jrr tolkien"), ("", ""), (None, ""), ], ) def test_names_that_should_agree(self, name: str | None, expected: str) -> None: assert normalize_author(name) == expected def test_two_people_are_not_reduced_together(self) -> None: """Surname plus initial would collide unrelated writers; it is not used.""" assert normalize_author("Charles Dickens") != normalize_author("Colin Dexter") def test_a_list_is_left_alone(self) -> None: """More than one comma is a list or a suffix, and guessing does more harm.""" assert normalize_author("Smith, John, Jr.") == "smith john jr" class TestNormalizeIdentifier: """Identifiers only help if the same edition produces the same key.""" def test_isbn_10_and_isbn_13_are_one_key(self) -> None: assert normalize_identifier("isbn-10", "0486282112") == "isbn:9780486282114" assert normalize_identifier("isbn-13", "9780486282114") == "isbn:9780486282114" @pytest.mark.parametrize( "written", ["978-0-486-28211-4", "978 0 486 28211 4", "9780486282114"] ) def test_formatting_is_not_part_of_an_isbn(self, written: str) -> None: assert normalize_identifier("isbn", written) == "isbn:9780486282114" def test_an_isbn_that_fails_its_checksum_is_no_evidence(self) -> None: assert normalize_identifier("isbn-13", "9780486282115") is None def test_uuids_are_refused(self) -> None: """Generated per build, so they only re-find what the hash check catches.""" assert normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None assert normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None def test_other_schemes_keep_their_own_key(self) -> None: assert normalize_identifier("asin", "B000FC0PDA") == "asin:b000fc0pda" assert normalize_identifier("ASIN", "b000fc0pda") == "asin:b000fc0pda" def test_something_too_short_is_not_evidence(self) -> None: """A Calibre id of "42" would otherwise pair two unrelated books.""" assert normalize_identifier("calibre", "42") is None @pytest.mark.parametrize(("name", "value"), [("", "1234567"), ("asin", "")]) def test_half_an_identifier_is_no_identifier(self, name: str, value: str) -> None: assert normalize_identifier(name, value) is None class TestIsbnConversion: def test_isbn_10_converts_to_its_isbn_13(self) -> None: assert isbn10_to_isbn13("0486282112") == "9780486282114" def test_a_trailing_x_is_a_digit(self) -> None: assert isbn10_to_isbn13("043942089X") == "9780439420891" @pytest.mark.parametrize("isbn", ["0486282113", "9780486282114", "nonsense"]) def test_anything_that_is_not_an_isbn_10_converts_to_nothing(self, isbn: str) -> None: assert isbn10_to_isbn13(isbn) is None